Compare commits

..
12 Commits
Author SHA1 Message Date
twooey 61ea299b0e Fix Stripe authorization lifecycle timing 2026-08-28 17:18:54 -04:00
twooey 21038d68ca Replace Helcim integration with WooCommerce Stripe 2026-08-28 16:59:08 -04:00
twooey 197938f482 pushing stuff that was worked on 2026-08-28 15:17:46 -04:00
twooeyandClaude Sonnet 5 773883a0f3 Evolve the member discount into a sitewide stepped promo
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
2026-08-28 12:20:18 -04:00
twooeyandClaude Sonnet 5 13e7362579 Replace the single is_free_member flag with a real member-tier system
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
2026-08-28 11:56:06 -04:00
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
twooeyandClaude Sonnet 5 8b149a672d Restrict production wp-admin/wp-login to an IP allowlist (opt-in)
New WP_ADMIN_ALLOWED_IPS env var (space-separated CIDR ranges, matching
Caddy's remote_ip syntax directly). Defaults to 0.0.0.0/0 ::/0 (today's
open behavior) when unset, resolved at the Compose level rather than in
the Caddyfile so an unset var can never leave `remote_ip` with zero
arguments and fail Caddy's startup. admin-ajax.php stays exempt since
WooCommerce routes logged-out storefront AJAX through it. Production
only — staging is already LAN-only.

Verified live: a scratch Caddy instance on a Docker network, with a
sibling container's actual observed source IP used to prove both the
blocked (403 on wp-admin/wp-login, everything else unaffected) and
allowed (matching CIDR lets it through) cases, plus `docker compose
config` to confirm the unset-var default resolves correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
2026-08-28 11:15:55 -04:00
twooeyandClaude Sonnet 5 3143128798 Block web access to bookstore-core's tests/vendor/composer files
They're bind-mounted straight into the webroot (docker-compose.*.yml) and
nothing strips them on a git-checkout deploy, so php_fastcgi/file_server
would otherwise execute/serve them directly. 404 instead of 403 so their
existence isn't confirmed. Verified live against a scratch Caddy instance
— plain `*` matches across path segments in Caddy 2.11.4 (the `**` token
I first tried did not match nested paths at all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
2026-08-28 11:11:11 -04:00
twooeyandClaude Sonnet 5 5d8533f290 Add automated tests for the 25% pricing formula and $25/$15 shipping thresholds
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
2026-08-28 10:58:29 -04:00
twooeyandClaude Sonnet 5 a733ba4bfd Week 4 (scoped): BooksRun live price/stock check + daily FTP sync stub
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
2026-08-27 21:29:37 -04:00
twooeyandClaude Sonnet 5 4079bfceac Week 3: checkout + order engine (Helcim gateway, pre-charge recheck, order state machine)
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
2026-08-27 20:03:32 -04:00
twooey 73c64d94c1 Revise payment flow in the prelaunch plan: immediate charge, not hold-then-capture
Updated after researching Helcim's actual WooCommerce integration: their
official plugin only supports immediate charge, not a hold/capture step tied
to a WooCommerce order (pre-authorizations exist on Helcim's platform but
only through POS/Virtual Terminal/Payment Pages, disconnected from
WooCommerce). A custom integration against Helcim's API to preserve the
original authorize-then-capture design was scoped and found feasible, but
decided against: checking supplier stock/price/margin live immediately
before charging closes most of the same risk window anyway, since no
dropship design actually reserves the supplier's physical inventory either
way.

Updated in place: Executive Summary launch definition, the custom plugin's
order state machine, Launch Business Rules, Week 3 deliverables, the
Payments/fraud work breakdown, the Staging Test Matrix, Launch Gates, and
the working-capital note — replacing authorize/capture/void language with
charge/refund throughout.

Added Appendix B documenting the decision in full: what was evaluated, why
immediate-charge was chosen over building a custom preauth/capture
integration, and the residual risks accepted (a post-charge failure becomes
a refund — fee loss only, not a structural loss — and a one-off
unfulfillable order can be manually covered via retail fallback rather than
requiring automated multi-supplier failover for launch).
2026-08-27 17:40:58 -04:00
47 changed files with 5244 additions and 47 deletions

No files matched your search

+22 -3
View File
@@ -41,12 +41,24 @@ BACKUP_RETENTION_DAYS=14
# staging: gutenberg_test production: booksrun,ingram
ACTIVE_SUPPLIER_ADAPTERS=gutenberg_test
BOOKSRUN_API_KEY=
# Week 4: bulk FTP inventory feed — placeholders, not yet consumed by any
# code beyond Import\BooksRunOfferSync::is_ftp_configured()'s empty check.
BOOKSRUN_FTP_HOST=
BOOKSRUN_FTP_PORT=
BOOKSRUN_FTP_USER=
BOOKSRUN_FTP_PASSWORD=
BOOKSRUN_FTP_REMOTE_PATH=
INGRAM_API_KEY=
INGRAM_ACCOUNT_ID=
# --- Payments (Helcim) ---
HELCIM_API_TOKEN=
HELCIM_ACCOUNT_ID=
# --- Payments (official WooCommerce Stripe gateway) ---
# Use test keys in dev/staging and live keys only in production. The deploy
# writes these values to gitignored Docker secret files before configuring
# the plugin. Webhook signing secret is optional but strongly recommended.
STRIPE_MODE=test
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# --- Marketing ---
MAILERLITE_API_KEY=
@@ -54,3 +66,10 @@ MAILERLITE_API_KEY=
# --- Hardcover (AO3-style tag browsing — genre/mood/content-warning/tag sync) ---
# Get a token: hardcover.app account settings -> Hardcover API -> New API Key
HARDCOVER_API_TOKEN=
# --- Admin access restriction (production only) ---
# Space-separated IPs/CIDR ranges allowed to reach /wp-admin and
# /wp-login.php (matches Caddy's `remote_ip` syntax directly — no quotes,
# space-separated even though most other values here are one token).
# Leave blank to leave wp-admin reachable from anywhere (today's behavior).
WP_ADMIN_ALLOWED_IPS=
+1
View File
@@ -7,6 +7,7 @@
/.poll-deploy-*.lock
vendor/
node_modules/
.phpunit.result.cache
*.log
.DS_Store
/.claude/
+12 -1
View File
@@ -21,7 +21,7 @@ $(shell grep -Ev '^(DB_PASSWORD|DB_ROOT_PASSWORD)=' .env.$(ENV) > $(COMPOSE_ENV_
COMPOSE = docker compose -p bookstore-$(ENV) -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file $(COMPOSE_ENV_FILE)
CLEANUP = trap 'rm -f $(COMPOSE_ENV_FILE)' EXIT;
.PHONY: up down ps logs shell wp deploy backup
.PHONY: up down ps logs shell wp test deploy backup
up:
$(CLEANUP) $(COMPOSE) up -d --build
@@ -43,6 +43,17 @@ shell:
wp:
$(CLEANUP) $(COMPOSE) exec -u www-data wordpress wp $(ARGS)
# bookstore-core's PHPUnit suite; composer install is idempotent so this
# stays a single target rather than a separate "test-install" step. The
# plugin dir is bind-mounted from the host at the host user's uid, which
# www-data (uid 33 in the container) can't write into — install as root
# (same "-u root yourself for one-off root debugging" pattern as `shell`
# above), then chown vendor/composer.lock back to the host uid/gid so
# they stay normal host-owned files afterward, not root-owned.
test:
$(CLEANUP) $(COMPOSE) exec -u root wordpress bash -c \
"cd wp-content/plugins/bookstore-core && composer install --no-cache && chown -R $(shell id -u):$(shell id -g) vendor composer.lock && vendor/bin/phpunit"
deploy:
$(CLEANUP) ./deploy/deploy.sh $(ENV)
+135 -8
View File
@@ -5,10 +5,14 @@ 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).
This week's scope (Week 1 of the 5-week plan): environments, deploy
pipeline, and the WooCommerce/HPOS plugin scaffold. Catalog schema, pricing,
supplier adapters, and the order state machine are Week 2+ and live under
`wp-content/plugins/bookstore-core/includes/`, currently empty.
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
@@ -97,6 +101,48 @@ 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
@@ -140,13 +186,94 @@ run a real restore drill:
`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
- Catalog tables, pricing engine, supplier adapters, order state machine —
Week 2–4, see the design doc.
- 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 test/lint automation — nothing here runs tests yet because there's no
application code to test yet.
- 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.
+1 -1
View File
@@ -43,7 +43,7 @@ $COMPOSE run --rm -T -u www-data wordpress tar -czf - -C /var/www/html/wp-conten
> "$BACKUP_DIR/uploads-${TIMESTAMP}.tar.gz"
echo "==> archiving environment config"
# Every API key (Booksrun/Ingram/Helcim/MailerLite/Hardcover), the WP admin
# Every API key (Booksrun/Ingram/Stripe/MailerLite/Hardcover), the WP admin
# bootstrap credentials, and both DB passwords live ONLY in this one
# gitignored host file — losing the host without this backed up loses all of
# it, even with the DB dump and uploads intact. Same trust model as the DB
+83 -2
View File
@@ -67,12 +67,52 @@ printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password"
# exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent).
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token"
# Stripe credentials are supplied through .env but mounted into the
# containers as files so secret values never become container environment
# variables. The official gateway copies them into its WordPress settings.
STRIPE_MODE_VALUE="$(env_get "$ENV_FILE" STRIPE_MODE)"
STRIPE_MODE_VALUE="${STRIPE_MODE_VALUE:-test}"
case "$STRIPE_MODE_VALUE" in
test|live) ;;
*) echo "STRIPE_MODE must be 'test' or 'live'" >&2; exit 1 ;;
esac
STRIPE_PUBLISHABLE_KEY_VALUE="$(env_get "$ENV_FILE" STRIPE_PUBLISHABLE_KEY)"
STRIPE_SECRET_KEY_VALUE="$(env_get "$ENV_FILE" STRIPE_SECRET_KEY)"
STRIPE_WEBHOOK_SECRET_VALUE="$(env_get "$ENV_FILE" STRIPE_WEBHOOK_SECRET)"
printf '%s' "$STRIPE_PUBLISHABLE_KEY_VALUE" > "$SECRETS_DIR/stripe_publishable_key"
printf '%s' "$STRIPE_SECRET_KEY_VALUE" > "$SECRETS_DIR/stripe_secret_key"
printf '%s' "$STRIPE_WEBHOOK_SECRET_VALUE" > "$SECRETS_DIR/stripe_webhook_secret"
if [[ -z "$STRIPE_PUBLISHABLE_KEY_VALUE" || -z "$STRIPE_SECRET_KEY_VALUE" ]]; then
echo " (Stripe keys not set in ${ENV_FILE} — Stripe will remain disabled)"
else
if [[ "$STRIPE_PUBLISHABLE_KEY_VALUE" != pk_${STRIPE_MODE_VALUE}_* ]]; then
echo "STRIPE_PUBLISHABLE_KEY does not match STRIPE_MODE=${STRIPE_MODE_VALUE}" >&2
exit 1
fi
if [[ "$STRIPE_SECRET_KEY_VALUE" != sk_${STRIPE_MODE_VALUE}_* && "$STRIPE_SECRET_KEY_VALUE" != rk_${STRIPE_MODE_VALUE}_* ]]; then
echo "STRIPE_SECRET_KEY does not match STRIPE_MODE=${STRIPE_MODE_VALUE}" >&2
exit 1
fi
fi
if [[ -n "$STRIPE_WEBHOOK_SECRET_VALUE" && "$STRIPE_WEBHOOK_SECRET_VALUE" != whsec_* ]]; then
echo "STRIPE_WEBHOOK_SECRET must start with whsec_" >&2
exit 1
fi
# Same pattern again — optional, written unconditionally.
# Integration\BooksRunAdapter::is_configured() is false until a real key
# lands in BOOKSRUN_API_KEY. BOOKSRUN_FTP_PASSWORD is a placeholder: no
# FTP access exists yet either (Import\BooksRunOfferSync is a stub), but
# the secret-file path is wired up now so nothing changes here once it does.
BOOKSRUN_API_KEY_VALUE="$(env_get "$ENV_FILE" BOOKSRUN_API_KEY)"
printf '%s' "$BOOKSRUN_API_KEY_VALUE" > "$SECRETS_DIR/booksrun_api_key"
BOOKSRUN_FTP_PASSWORD_VALUE="$(env_get "$ENV_FILE" BOOKSRUN_FTP_PASSWORD)"
printf '%s' "$BOOKSRUN_FTP_PASSWORD_VALUE" > "$SECRETS_DIR/booksrun_ftp_password"
# 644, not 600: the db/wordpress/cron containers read this as their own
# (non-host-matching) container UID, e.g. www-data — chmod 600 made it
# unreadable to them. The containing directory (700, above) is what
# actually keeps other host users out; these just need to be world-readable
# within that already-restricted directory.
chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token"
chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token" "$SECRETS_DIR/stripe_publishable_key" "$SECRETS_DIR/stripe_secret_key" "$SECRETS_DIR/stripe_webhook_secret" "$SECRETS_DIR/booksrun_api_key" "$SECRETS_DIR/booksrun_ftp_password"
echo "==> building and starting ${ENVIRONMENT}"
$COMPOSE up -d --build
@@ -100,18 +140,59 @@ if ! $WP core is-installed; then
--skip-email
fi
# WordPress defaults to plain "?p=123" permalinks on a fresh install,
# which silently breaks routing for /my-account/, the Store API
# (/wp-json/wc/store/v1/...), and any other pretty-URL path — confirmed
# directly (my-account fell through to the homepage instead of 404ing,
# which made the problem non-obvious). "Rewrite rules are empty" further
# down in this script was this same root cause going unfixed on every
# deploy until now.
echo "==> setting pretty permalinks (required for Store API / my-account routing)"
$WP rewrite structure '/%postname%/'
$WP rewrite flush --hard
echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)"
$WP theme install blocksy --activate
echo "==> installing/activating required plugins"
$WP plugin install woocommerce redis-cache blocksy-companion --activate
$WP plugin install woocommerce woocommerce-gateway-stripe redis-cache blocksy-companion --activate
$WP plugin activate bookstore-core
$WP redis enable || true
# The distributor workflow depends on Stripe authorizing at checkout and
# capturing later when SupplierOrderJob moves the order to Processing.
# Read the mounted files inside the container so credentials never appear in
# this command line or deployment logs.
echo "==> configuring Stripe from ${ENV_FILE} (authorize and capture later)"
$WP eval '
$read_secret = static function (string $env): string {
$path = getenv($env);
return $path && is_readable($path) ? trim((string) file_get_contents($path)) : "";
};
$mode = getenv("STRIPE_MODE") === "live" ? "live" : "test";
$publishable = $read_secret("STRIPE_PUBLISHABLE_KEY_FILE");
$secret = $read_secret("STRIPE_SECRET_KEY_FILE");
$webhook = $read_secret("STRIPE_WEBHOOK_SECRET_FILE");
$settings = get_option("woocommerce_stripe_settings", []);
$settings["enabled"] = $publishable !== "" && $secret !== "" ? "yes" : "no";
$settings["capture"] = "no";
$settings["testmode"] = $mode === "test" ? "yes" : "no";
$settings[$mode === "test" ? "test_publishable_key" : "publishable_key"] = $publishable;
$settings[$mode === "test" ? "test_secret_key" : "secret_key"] = $secret;
$settings[$mode === "test" ? "test_webhook_secret" : "webhook_secret"] = $webhook;
update_option("woocommerce_stripe_settings", $settings);
'
echo "==> enabling WooCommerce HPOS (custom order tables)"
$WP option update woocommerce_custom_orders_table_enabled yes
$WP option update woocommerce_custom_orders_table_data_sync_enabled yes
# Membership\Registration hooks onto WooCommerce's own My Account
# registration form (design doc §07) rather than a bespoke signup form —
# it's disabled by default, so nothing shows up without this.
echo "==> enabling WooCommerce account registration (member signup)"
$WP option update woocommerce_enable_myaccount_registration yes
# WooCommerce's default (unset -> '1:1') force-crops every product
# thumbnail to a square — confirmed directly: a 335x500 portrait book cover
# was landing at a hard-cropped 300x300 shop-grid thumbnail, cutting off
+6
View File
@@ -19,6 +19,12 @@ services:
- "443:443"
environment:
SITE_DOMAIN: ${SITE_DOMAIN}
# Default keeps wp-admin open to everyone (today's behavior) when
# unset — resolved here, not in the Caddyfile, since Compose would
# otherwise pass an *empty* string rather than an unset var, and
# Caddy's own {$VAR:default} placeholder only applies to a var
# that's truly absent, not an empty one.
WP_ADMIN_ALLOWED_IPS: ${WP_ADMIN_ALLOWED_IPS:-0.0.0.0/0 ::/0}
volumes:
- ./docker/caddy/Caddyfile.production:/etc/caddy/Caddyfile:ro
- wp_core:/var/www/html:ro
+31 -8
View File
@@ -22,11 +22,12 @@
# bind mount never touches the file's content, only its path, so it's
# immune to this entirely.
#
# The remaining supplier/payment keys below are still passed the old ${VAR}
# way and remain exposed to the same class of bug — nothing consumes them
# yet (bookstore-core is still a stub for those), so when that code is
# written it should read `_FILE` variants the same way HARDCOVER_API_TOKEN
# now does below.
# The remaining supplier keys below are still passed the old ${VAR} way and
# remain exposed to the same class of bug — nothing consumes them yet
# (bookstore-core is still a stub for those), so when that code is written
# it should read `_FILE` variants the same way HARDCOVER_API_TOKEN,
# BOOKSRUN_API_KEY/BOOKSRUN_FTP_PASSWORD
# (Week 4) now do below.
x-bookstore-env: &bookstore-env
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${DB_NAME}
@@ -42,13 +43,25 @@ x-bookstore-env: &bookstore-env
# Passed through for the bookstore-core plugin's supplier adapters /
# payment integration (design doc §04/§05) — not read by WordPress core.
ACTIVE_SUPPLIER_ADAPTERS: ${ACTIVE_SUPPLIER_ADAPTERS:-gutenberg_test}
BOOKSRUN_API_KEY: ${BOOKSRUN_API_KEY:-}
INGRAM_API_KEY: ${INGRAM_API_KEY:-}
INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-}
HELCIM_API_TOKEN: ${HELCIM_API_TOKEN:-}
HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-}
MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-}
HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token
STRIPE_MODE: ${STRIPE_MODE:-test}
STRIPE_PUBLISHABLE_KEY_FILE: /run/secrets/stripe_publishable_key
STRIPE_SECRET_KEY_FILE: /run/secrets/stripe_secret_key
STRIPE_WEBHOOK_SECRET_FILE: /run/secrets/stripe_webhook_secret
BOOKSRUN_API_KEY_FILE: /run/secrets/booksrun_api_key
# Week 4: bulk FTP inventory feed connection details — BOOKSRUN_FTP_HOST
# empty means Import\BooksRunOfferSync::is_ftp_configured() is false and
# the daily sync job stays a no-op (see that class). None of these are
# read by any code path yet beyond that one boolean check — the actual
# connect+parse logic is still a stub pending real access/a sample feed.
BOOKSRUN_FTP_HOST: ${BOOKSRUN_FTP_HOST:-}
BOOKSRUN_FTP_PORT: ${BOOKSRUN_FTP_PORT:-}
BOOKSRUN_FTP_USER: ${BOOKSRUN_FTP_USER:-}
BOOKSRUN_FTP_REMOTE_PATH: ${BOOKSRUN_FTP_REMOTE_PATH:-}
BOOKSRUN_FTP_PASSWORD_FILE: /run/secrets/booksrun_ftp_password
services:
db:
@@ -98,6 +111,11 @@ services:
- ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/hardcover_api_token:/run/secrets/hardcover_api_token:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_publishable_key:/run/secrets/stripe_publishable_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_secret_key:/run/secrets/stripe_secret_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_webhook_secret:/run/secrets/stripe_webhook_secret:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_api_key:/run/secrets/booksrun_api_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro
healthcheck:
# No HTTP endpoint to hit directly — php-fpm speaks FastCGI on 9000,
# not HTTP, and this image has no `nc`. bash's /dev/tcp redirection
@@ -152,6 +170,11 @@ services:
- ./docker/cron/entrypoint.sh:/usr/local/bin/cron-entrypoint.sh:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/hardcover_api_token:/run/secrets/hardcover_api_token:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_publishable_key:/run/secrets/stripe_publishable_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_secret_key:/run/secrets/stripe_secret_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_webhook_secret:/run/secrets/stripe_webhook_secret:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_api_key:/run/secrets/booksrun_api_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro
volumes:
db_data:
+21
View File
@@ -10,6 +10,27 @@
max_size 32MB
}
# bookstore-core's tests/, vendor/, composer.json/.lock, and
# phpunit.xml.dist ship in the repo (and are bind-mounted straight into
# this container — see docker-compose.*.yml) so they exist under the
# webroot, but nothing about a git-checkout deploy strips them before
# they land here. 404 instead of 403 so their existence isn't confirmed.
@dev_only path /wp-content/plugins/bookstore-core/tests/* /wp-content/plugins/bookstore-core/vendor/* /wp-content/plugins/bookstore-core/composer.json /wp-content/plugins/bookstore-core/composer.lock /wp-content/plugins/bookstore-core/phpunit.xml*
respond @dev_only 404
# WP_ADMIN_ALLOWED_IPS (.env.production) — defaults to 0.0.0.0/0 ::/0
# (unrestricted) in docker-compose.production.yml when unset. Excludes
# admin-ajax.php: WordPress/WooCommerce route logged-out-visitor AJAX
# (cart fragments, nonces, some add-to-cart flows) through that file
# even though it lives under /wp-admin/ — blocking it by IP would break
# the storefront for every visitor, not just admin access.
@blocked_admin {
path /wp-admin* /wp-login.php
not path /wp-admin/admin-ajax.php
not remote_ip {$WP_ADMIN_ALLOWED_IPS}
}
respond @blocked_admin 403
php_fastcgi wordpress:9000
file_server
+8
View File
@@ -37,6 +37,14 @@
max_size 32MB
}
# bookstore-core's tests/, vendor/, composer.json/.lock, and
# phpunit.xml.dist ship in the repo (and are bind-mounted straight into
# this container — see docker-compose.*.yml) so they exist under the
# webroot, but nothing about a git-checkout deploy strips them before
# they land here. 404 instead of 403 so their existence isn't confirmed.
@dev_only path /wp-content/plugins/bookstore-core/tests/* /wp-content/plugins/bookstore-core/vendor/* /wp-content/plugins/bookstore-core/composer.json /wp-content/plugins/bookstore-core/composer.lock /wp-content/plugins/bookstore-core/phpunit.xml*
respond @dev_only 404
php_fastcgi wordpress:9000
file_server
}
@@ -4,7 +4,7 @@
* Description: Owns catalog, pricing, supplier routing, and order-state business logic for the bookstore. See docs/ for the technical design document.
* Version: 0.2.0
* Requires PHP: 8.1
* Requires Plugins: woocommerce
* Requires Plugins: woocommerce, woocommerce-gateway-stripe
* Author: Bookstore
*/
@@ -13,7 +13,7 @@ defined('ABSPATH') || exit;
define('BSC_PLUGIN_FILE', __FILE__);
define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('BSC_VERSION', '0.2.0');
define('BSC_SCHEMA_VERSION', '1');
define('BSC_SCHEMA_VERSION', '4');
spl_autoload_register(function (string $class) {
$prefix = 'Bookstore\\Core\\';
@@ -58,12 +58,45 @@ add_filter('woocommerce_shipping_methods', function (array $methods) {
return $methods;
});
// Membership tiers/benefits: design doc §07 extension (see includes/Membership/).
\Bookstore\Core\Membership\MemberDiscount::register();
\Bookstore\Core\Membership\Registration::register();
add_filter('woocommerce_get_settings_pages', function (array $settings) {
$settings[] = new \Bookstore\Core\Admin\TierSettings();
return $settings;
});
add_action('init', function () {
\Bookstore\Core\Taxonomy\TagTaxonomies::register();
});
\Bookstore\Core\Taxonomy\TagDisplay::register();
// Week 3: checkout + order engine (design doc §04/Appendix B).
\Bookstore\Core\Order\OrderStatuses::register();
\Bookstore\Core\Order\CheckoutRecheck::register();
\Bookstore\Core\Order\SupplierOrderJob::register();
// Week 4: BooksRun live per-title price check (Order\PriceRecheck) plus
// the daily bulk FTP offer sync (payload stubbed until real FTP access
// exists — see Import\BooksRunOfferSync).
\Bookstore\Core\Import\BooksRunOfferSyncJob::register();
add_filter('woocommerce_email_classes', function (array $emails) {
$emails['BSC_Exception_Review_Email'] = new \Bookstore\Core\Email\ExceptionReviewEmail();
$emails['BSC_Shipped_Email'] = new \Bookstore\Core\Email\ShippedEmail();
return $emails;
});
// Recorded against the order-audit ledger for every refund — now mostly
// just admin-initiated manual refunds after a real capture, since
// SupplierOrderJob's automatic pre-capture failure path is a void
// (AuditTrail::record_void(), zero cost) rather than a refund.
add_action('woocommerce_order_refunded', function (int $order_id, int $refund_id) {
\Bookstore\Core\Order\AuditTrail::record_refund($order_id);
}, 10, 2);
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('bookstore', \Bookstore\Core\Cli\Commands::class);
}
@@ -5,9 +5,18 @@
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10",
"brain/monkey": "^2.6"
},
"autoload": {
"psr-4": {
"Bookstore\\Core\\": "includes/"
}
},
"autoload-dev": {
"psr-4": {
"Bookstore\\Core\\Tests\\": "tests/"
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,160 @@
<?php
namespace Bookstore\Core\Admin;
use Bookstore\Core\Membership\Tier;
defined('ABSPATH') || exit;
/**
* First admin-settings screen this plugin has ever needed (confirmed: no
* WC_Settings_Page/add_menu_page precedent existed before this) —
* WooCommerce > Settings > Membership Tiers. WooCommerce's Settings API
* has no built-in repeater field for a dynamic tier/benefit list, so
* rather than fighting it, output()/save() are fully overridden with
* plain hand-rolled markup/POST handling instead of the usual
* get_settings()-array approach — WC_Admin_Settings still wraps our tab
* in its own <form> with a nonce (verified before woocommerce_settings_
* save_{id} fires), so no separate nonce handling is needed here.
*
* Only the two benefit types that currently have real code behind them
* (Shipping\BSC_Free_Shipping, Membership\MemberDiscount) get dedicated
* fields — a third benefit type needs one more fields block here, same
* as it needs a small new class at whatever hook enforces it.
*/
class TierSettings extends \WC_Settings_Page
{
public function __construct()
{
$this->id = 'bsc_tiers';
$this->label = 'Membership Tiers';
parent::__construct();
}
public function output(): void
{
if (!current_user_can('manage_woocommerce')) {
return;
}
$errors = get_transient('bsc_tier_settings_errors') ?: [];
delete_transient('bsc_tier_settings_errors');
foreach ($errors as $error) {
echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
}
$tiers = Tier::all();
?>
<h2>Membership Tiers</h2>
<p>Each tier's benefits apply automatically to whoever is assigned to it — see <code>wp bookstore tier assign &lt;user&gt; &lt;slug&gt;</code> for assigning members from the command line.</p>
<table class="widefat" style="max-width: 900px;">
<thead>
<tr>
<th>Name / slug</th>
<th>Sort order</th>
<th>Free shipping threshold</th>
<th>Member discount</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($tiers as $tier): $fs = $tier['benefits']['free_shipping_threshold'] ?? null; $sd = $tier['benefits']['step_discount'] ?? null; ?>
<tr>
<td>
<input type="text" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][name]" value="<?php echo esc_attr($tier['name']); ?>" />
<br><code><?php echo esc_html($tier['slug']); ?></code>
</td>
<td>
<input type="number" style="width:5em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][sort_order]" value="<?php echo esc_attr($tier['sort_order']); ?>" />
</td>
<td>
<label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][free_shipping_threshold][enabled]" value="1" <?php checked($fs !== null); ?> /> enabled</label><br>
$<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][free_shipping_threshold][threshold]" value="<?php echo esc_attr($fs['threshold'] ?? '0.00'); ?>" />
</td>
<td>
<label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][enabled]" value="1" <?php checked($sd !== null); ?> /> enabled</label><br>
$<input type="number" step="0.01" style="width:5em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][discount_per_step]" value="<?php echo esc_attr($sd['discount_per_step'] ?? '0.00'); ?>" />
off per $<input type="number" step="0.01" style="width:5em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][step_amount]" value="<?php echo esc_attr($sd['step_amount'] ?? '0.00'); ?>" />
spent, capped at $<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][max_spend]" value="<?php echo esc_attr($sd['max_spend'] ?? '0.00'); ?>" />
</td>
<td>
<?php if ($tier['slug'] !== Tier::GUEST_SLUG): ?>
<label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][delete]" value="1" /> delete</label>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<tr>
<td><input type="text" name="bsc_new_tier[slug]" placeholder="slug (e.g. gold)" /><br><input type="text" name="bsc_new_tier[name]" placeholder="Display name" /></td>
<td><input type="number" style="width:5em" name="bsc_new_tier[sort_order]" value="<?php echo esc_attr((count($tiers)) * 10); ?>" /></td>
<td colspan="2"><em>New tier — leave benefit fields on existing rows for now, add them after saving.</em></td>
<td></td>
</tr>
</tbody>
</table>
<?php
}
public function save(): void
{
if (!current_user_can('manage_woocommerce')) {
return;
}
$errors = [];
$posted_tiers = $_POST['bsc_tier'] ?? [];
if (is_array($posted_tiers)) {
foreach ($posted_tiers as $tier_id => $fields) {
$tier_id = (int) $tier_id;
$tier = Tier::find($tier_id);
if (!$tier) {
continue;
}
if (!empty($fields['delete'])) {
$result = Tier::delete($tier['slug']);
if ($result !== true) {
$errors[] = "Could not delete '{$tier['name']}': {$result}";
}
continue;
}
$benefits = [];
if (!empty($fields['free_shipping_threshold']['enabled'])) {
$benefits['free_shipping_threshold'] = [
'threshold' => (float) ($fields['free_shipping_threshold']['threshold'] ?? 0),
];
}
if (!empty($fields['step_discount']['enabled'])) {
$benefits['step_discount'] = [
'step_amount' => (float) ($fields['step_discount']['step_amount'] ?? 0),
'discount_per_step' => (float) ($fields['step_discount']['discount_per_step'] ?? 0),
'max_spend' => (float) ($fields['step_discount']['max_spend'] ?? 0),
];
}
Tier::update(
$tier_id,
sanitize_text_field($fields['name'] ?? $tier['name']),
(int) ($fields['sort_order'] ?? $tier['sort_order']),
$benefits
);
}
}
$new_tier = $_POST['bsc_new_tier'] ?? [];
if (!empty($new_tier['slug']) && !empty($new_tier['name'])) {
$slug = sanitize_title($new_tier['slug']);
if (Tier::find_by_slug($slug)) {
$errors[] = "A tier with slug '{$slug}' already exists.";
} else {
Tier::create($slug, sanitize_text_field($new_tier['name']), (int) ($new_tier['sort_order'] ?? 0));
}
}
if ($errors) {
set_transient('bsc_tier_settings_errors', $errors, 60);
}
}
}
@@ -6,6 +6,8 @@ defined('ABSPATH') || exit;
class SupplierOffer
{
public const SUPPLIER_BOOKSRUN = 'booksrun';
public static function table(): string
{
global $wpdb;
@@ -71,6 +73,36 @@ class SupplierOffer
return $row ?: null;
}
/**
* Week 4: persists a live per-ISBN recheck (Order\PriceRecheck) back
* into the SAME offer_id row rather than just returning an in-memory
* value — Order\SupplierOrderJob::run() re-reads a pinned offer_id
* from the audit trail at fulfillment time to confirm it's still
* sellable, so the live values have to actually land in the table or
* that later read sees the stale row again and this feature does
* nothing. $wpdb->update() returning 0 (row unchanged, or gone) is
* not an error — only `=== false` is, matching insert()/
* delete_for_isbn() above.
*/
public static function update_live_snapshot(int $offer_id, int $stock_qty, float $base_price, float $supplier_ship, string $status): void
{
global $wpdb;
$result = $wpdb->update(
self::table(),
[
'stock_qty' => $stock_qty,
'base_price' => $base_price,
'supplier_ship' => $supplier_ship,
'status' => $status,
'fetched_at' => current_time('mysql', true),
],
['offer_id' => $offer_id]
);
if ($result === false) {
throw new \RuntimeException('bsc_supplier_offer live-snapshot update failed: ' . $wpdb->last_error . " (offer_id: {$offer_id})");
}
}
public static function delete_for_isbn(string $isbn13): void
{
global $wpdb;
@@ -3,10 +3,14 @@
namespace Bookstore\Core\Cli;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Catalog\Work;
use Bookstore\Core\Import\BooksRunFtpNotConfiguredException;
use Bookstore\Core\Import\BooksRunOfferSync;
use Bookstore\Core\Import\GutenbergImporter;
use Bookstore\Core\Import\SyntheticOfferGenerator;
use Bookstore\Core\Integration\HardcoverAdapter;
use Bookstore\Core\Membership\Tier;
use Bookstore\Core\Product\CoverSync;
use Bookstore\Core\Product\ProductSync;
use Bookstore\Core\Taxonomy\TagTaxonomies;
@@ -91,6 +95,43 @@ class Commands
));
}
/**
* Manually triggers the BooksRun daily bulk offer sync — the same
* code path the scheduled Action Scheduler job runs, for testing
* without waiting for the daily schedule.
*
* NOTE: the actual FTP connect+parse step is a deliberate placeholder
* (Import\BooksRunOfferSync::connect_and_parse_feed()) until real FTP
* access exists — this command failing loudly is expected, by
* design, until then.
*
* ## EXAMPLES
*
* wp bookstore sync-booksrun-offers
*
* @subcommand sync-booksrun-offers
*/
public function sync_booksrun_offers($args, $assoc_args)
{
try {
$result = BooksRunOfferSync::sync_all(500, function (int $processed) {
\WP_CLI::log(" processed {$processed} rows...");
});
} catch (BooksRunFtpNotConfiguredException $e) {
\WP_CLI::error($e->getMessage());
}
foreach ($result['failed'] as $failure) {
\WP_CLI::warning($failure);
}
\WP_CLI::success(sprintf(
'BooksRun offer sync: %d processed (%d failed).',
$result['processed'],
count($result['failed'])
));
}
/**
* Creates/updates one WooCommerce product per catalog Work.
*
@@ -118,6 +159,70 @@ class Commands
));
}
/**
* Forces a specific ISBN's supplier offer into an explicit state, for
* reproducible checkout-recheck testing. SyntheticOfferGenerator's
* status/stock rolls are random (Week 2), so there's otherwise no way
* to reliably force one ISBN into e.g. "just went out of stock" for a
* Week 3 checkout test.
*
* ## OPTIONS
*
* <isbn13>
* : ISBN-13 to force.
*
* <status>
* : One of: active, stale, discontinued.
*
* [--stock=<number>]
* : Stock quantity (default: 10 for active, 0 for stale/discontinued).
*
* [--base-price=<number>]
* : Supplier base price (default 5.00).
*
* [--supplier-ship=<number>]
* : Supplier shipping cost (default 2.00).
*
* ## EXAMPLES
*
* wp bookstore force-offer-status 9781234567890 active --base-price=99.00
* wp bookstore force-offer-status 9781234567890 discontinued
*
* @subcommand force-offer-status
*/
public function force_offer_status($args, $assoc_args)
{
[$isbn13, $status] = $args;
if (!in_array($status, ['active', 'stale', 'discontinued'], true)) {
\WP_CLI::error('status must be one of: active, stale, discontinued');
}
$stock = isset($assoc_args['stock']) ? (int) $assoc_args['stock'] : ($status === 'active' ? 10 : 0);
$base_price = (float) ($assoc_args['base-price'] ?? 5.00);
$supplier_ship = (float) ($assoc_args['supplier-ship'] ?? 2.00);
SupplierOffer::delete_for_isbn($isbn13);
SupplierOffer::insert(
$isbn13,
'gutenberg_test',
'good',
$stock,
$base_price,
$supplier_ship,
$status,
gmdate('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS)
);
\WP_CLI::success(sprintf(
'Forced offer for %s: status=%s, stock=%d, base_price=%.2f, supplier_ship=%.2f.',
$isbn13,
$status,
$stock,
$base_price,
$supplier_ship
));
}
/**
* Syncs genre/mood/content-warning/freeform tags from Hardcover onto
* each Work's product (design doc-adjacent: AO3-style tag browsing).
@@ -285,4 +390,182 @@ class Commands
$result['processed']
));
}
/**
* Lists membership tiers and their configured benefits.
*
* ## EXAMPLES
*
* wp bookstore tier-list
*
* @subcommand tier-list
*/
public function tier_list($args, $assoc_args)
{
$items = array_map(function (array $tier) {
return [
'slug' => $tier['slug'],
'name' => $tier['name'],
'sort_order' => $tier['sort_order'],
'benefits' => wp_json_encode($tier['benefits']),
];
}, Tier::all());
\WP_CLI\Utils\format_items('table', $items, ['slug', 'name', 'sort_order', 'benefits']);
}
/**
* Creates a new, benefit-less membership tier — add benefits with
* tier-set-benefit afterward.
*
* ## OPTIONS
*
* <slug>
* : Unique slug (e.g. gold).
*
* <name>
* : Display name.
*
* [--sort-order=<number>]
* : Display order (default 0).
*
* ## EXAMPLES
*
* wp bookstore tier-create gold "Gold Member" --sort-order=20
*
* @subcommand tier-create
*/
public function tier_create($args, $assoc_args)
{
[$slug, $name] = $args;
if (Tier::find_by_slug($slug)) {
\WP_CLI::error("A tier with slug '{$slug}' already exists.");
}
Tier::create($slug, $name, (int) ($assoc_args['sort-order'] ?? 0));
\WP_CLI::success("Created tier '{$slug}'.");
}
/**
* Sets (or replaces) one benefit on a tier. Benefit types with real
* code behind them today: free_shipping_threshold (Shipping\
* BSC_Free_Shipping, expects {"threshold": <number>}) and
* step_discount (Membership\MemberDiscount, expects {"step_amount":
* <number>, "discount_per_step": <number>, "max_spend": <number>} —
* e.g. step_amount=50/discount_per_step=5/max_spend=200 means $5 off
* per $50 spent, capped at $20 off). Any other type is stored but
* has no runtime effect until something reads it — see the class
* docblocks for why.
*
* ## OPTIONS
*
* <slug>
* : Tier slug.
*
* <type>
* : Benefit type key.
*
* <json-config>
* : Benefit config as a JSON object.
*
* ## EXAMPLES
*
* wp bookstore tier-set-benefit free_member step_discount '{"step_amount":50,"discount_per_step":5,"max_spend":200}'
*
* @subcommand tier-set-benefit
*/
public function tier_set_benefit($args, $assoc_args)
{
[$slug, $type, $json_config] = $args;
$config = json_decode($json_config, true);
if (!is_array($config)) {
\WP_CLI::error('json-config must be a valid JSON object.');
}
if (!Tier::set_benefit($slug, $type, $config)) {
\WP_CLI::error("No such tier '{$slug}'.");
}
\WP_CLI::success("Set '{$type}' on tier '{$slug}'.");
}
/**
* Removes one benefit from a tier.
*
* ## OPTIONS
*
* <slug>
* : Tier slug.
*
* <type>
* : Benefit type key to remove.
*
* ## EXAMPLES
*
* wp bookstore tier-remove-benefit free_member spend_discount
*
* @subcommand tier-remove-benefit
*/
public function tier_remove_benefit($args, $assoc_args)
{
[$slug, $type] = $args;
if (!Tier::remove_benefit($slug, $type)) {
\WP_CLI::error("No such tier '{$slug}'.");
}
\WP_CLI::success("Removed '{$type}' from tier '{$slug}'.");
}
/**
* Assigns a user to a tier.
*
* ## OPTIONS
*
* <user>
* : User ID, login, or email.
*
* <slug>
* : Tier slug to assign.
*
* ## EXAMPLES
*
* wp bookstore tier-assign customer@example.com free_member
*
* @subcommand tier-assign
*/
public function tier_assign($args, $assoc_args)
{
[$user_identifier, $slug] = $args;
$user = is_numeric($user_identifier)
? get_user_by('id', (int) $user_identifier)
: (get_user_by('email', $user_identifier) ?: get_user_by('login', $user_identifier));
if (!$user) {
\WP_CLI::error("No such user '{$user_identifier}'.");
}
if (!Tier::assign_member($user->ID, $slug)) {
\WP_CLI::error("No such tier '{$slug}'.");
}
\WP_CLI::success("Assigned user #{$user->ID} ({$user->user_email}) to tier '{$slug}'.");
}
/**
* Deletes a tier. Refuses if it's the guest tier (the universal
* fallback) or if any member is still assigned to it.
*
* ## OPTIONS
*
* <slug>
* : Tier slug to delete.
*
* ## EXAMPLES
*
* wp bookstore tier-delete gold
*
* @subcommand tier-delete
*/
public function tier_delete($args, $assoc_args)
{
[$slug] = $args;
$result = Tier::delete($slug);
if ($result !== true) {
\WP_CLI::error((string) $result);
}
\WP_CLI::success("Deleted tier '{$slug}'.");
}
}
@@ -0,0 +1,78 @@
<?php
namespace Bookstore\Core\Email;
defined('ABSPATH') || exit;
/**
* Admin-facing: fires whenever an order transitions into the exception
* queue (Order\OrderStatuses::EXCEPTION) for any reason — supplier
* recheck, margin recheck, fraud check, or post-charge supplier failure.
* Customer-facing failure is already communicated at checkout (an on-page
* notice) or via WooCommerce's stock refund email; this is purely the
* "someone needs to look at this" signal to the store operator.
*/
class ExceptionReviewEmail extends \WC_Email
{
public function __construct()
{
$this->id = 'bsc_exception_review';
$this->title = 'Order Needs Review';
$this->description = 'Sent to the store admin whenever an order is routed into the exception/review queue.';
$this->customer_email = false;
$this->heading = 'Order Needs Review';
$this->subject = '[{site_title}] Order #{order_number} needs review';
$this->template_html = 'emails/bsc-exception-review-html.php';
$this->template_plain = 'emails/bsc-exception-review-plain.php';
$this->template_base = BSC_PLUGIN_DIR . 'templates/';
add_action('woocommerce_order_status_' . \Bookstore\Core\Order\OrderStatuses::EXCEPTION, [$this, 'trigger'], 10, 2);
parent::__construct();
$this->recipient = $this->get_option('recipient', get_option('admin_email'));
}
public function trigger($order_id, $order = null): void
{
$this->setup_locale();
if ($order_id && !$order instanceof \WC_Order) {
$order = wc_get_order($order_id);
}
if ($order instanceof \WC_Order) {
$this->object = $order;
$this->recipient = $this->get_option('recipient', get_option('admin_email'));
$this->placeholders['{order_number}'] = $this->object->get_order_number();
}
if ($this->is_enabled() && $this->get_recipient()) {
$this->send($this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments());
}
$this->restore_locale();
}
public function get_content_html()
{
return wc_get_template_html($this->template_html, [
'order' => $this->object,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'sent_to_admin' => true,
'plain_text' => false,
'email' => $this,
], '', $this->template_base);
}
public function get_content_plain()
{
return wc_get_template_html($this->template_plain, [
'order' => $this->object,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'sent_to_admin' => true,
'plain_text' => true,
'email' => $this,
], '', $this->template_base);
}
}
@@ -0,0 +1,77 @@
<?php
namespace Bookstore\Core\Email;
defined('ABSPATH') || exit;
/**
* Customer-facing: fires on transition into Order\OrderStatuses::SHIPPED.
* A real, separate email rather than reusing WooCommerce's stock
* "Completed order" email — the state machine (design doc §04) treats
* Shipped and Complete as two distinct, meaningfully different moments
* for the customer, and WooCommerce's own Completed email already covers
* the Complete step.
*/
class ShippedEmail extends \WC_Email
{
public function __construct()
{
$this->id = 'bsc_shipped';
$this->title = 'Order Shipped';
$this->description = 'Sent to the customer once their order is marked Shipped.';
$this->customer_email = true;
$this->heading = 'Your order has shipped!';
$this->subject = 'Your {site_title} order #{order_number} has shipped';
$this->template_html = 'emails/bsc-shipped-html.php';
$this->template_plain = 'emails/bsc-shipped-plain.php';
$this->template_base = BSC_PLUGIN_DIR . 'templates/';
add_action('woocommerce_order_status_' . \Bookstore\Core\Order\OrderStatuses::SHIPPED, [$this, 'trigger'], 10, 2);
parent::__construct();
}
public function trigger($order_id, $order = null): void
{
$this->setup_locale();
if ($order_id && !$order instanceof \WC_Order) {
$order = wc_get_order($order_id);
}
if ($order instanceof \WC_Order) {
$this->object = $order;
$this->recipient = $this->object->get_billing_email();
$this->placeholders['{order_number}'] = $this->object->get_order_number();
}
if ($this->is_enabled() && $this->get_recipient()) {
$this->send($this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments());
}
$this->restore_locale();
}
public function get_content_html()
{
return wc_get_template_html($this->template_html, [
'order' => $this->object,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
], '', $this->template_base);
}
public function get_content_plain()
{
return wc_get_template_html($this->template_plain, [
'order' => $this->object,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'sent_to_admin' => false,
'plain_text' => true,
'email' => $this,
], '', $this->template_base);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Bookstore\Core\Import;
defined('ABSPATH') || exit;
/**
* A distinctly-typed exception so callers (the daily job, the manual CLI
* command) can tell "the known, deliberate placeholder" apart from a
* genuine future FTP failure once real connect+parse logic exists.
*/
class BooksRunFtpNotConfiguredException extends \RuntimeException
{
}
@@ -0,0 +1,100 @@
<?php
namespace Bookstore\Core\Import;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
defined('ABSPATH') || exit;
/**
* Week 4: a daily bulk pull of BooksRun's FTP inventory feed into
* bsc_supplier_offer, mirroring SyntheticOfferGenerator's exact batch
* shape. Everything here except connect_and_parse_feed() is real, working
* infrastructure — that one method is a DELIBERATE placeholder: no FTP
* host/credentials or sample feed file exist yet (confirmed directly with
* the user), so guessing at a file format here would silently write wrong
* price/stock data into bsc_supplier_offer the moment real access
* arrives. Implement connect_and_parse_feed() for real once a sample file
* is available — nothing else in this class should need to change.
*/
class BooksRunOfferSync
{
/** Checked by the scheduled job before running (Import\BooksRunOfferSyncJob) — not by the manual CLI command, which always attempts a real run and lets the exception surface. */
public static function is_ftp_configured(): bool
{
return getenv('BOOKSRUN_FTP_HOST') !== false && trim((string) getenv('BOOKSRUN_FTP_HOST')) !== '';
}
/**
* @param callable|null $on_progress function(int $processed): void
* @return array{processed:int, failed:string[]}
*/
public static function sync_all(int $batch_size = 500, ?callable $on_progress = null): array
{
$rows = self::connect_and_parse_feed(); // throws BooksRunFtpNotConfiguredException today
$processed = 0;
$failed = [];
foreach (array_chunk($rows, $batch_size) as $chunk) {
foreach ($chunk as $row) {
try {
self::upsert_offer_row($row);
} catch (\RuntimeException $e) {
$failed[] = ($row['isbn13'] ?? '?') . ': ' . $e->getMessage();
}
$processed++;
}
if ($on_progress) {
$on_progress($processed);
}
}
return ['processed' => $processed, 'failed' => $failed];
}
/**
* GUESSED row shape, matching SupplierOffer::insert()'s own parameter
* shape and nothing more — will need adjusting once a real feed
* format is seen. Skips ISBNs not already in our own catalog
* (Isbn::exists()) rather than writing orphaned offer rows no Work
* will ever query.
*/
private static function upsert_offer_row(array $row): void
{
$isbn13 = (string) ($row['isbn13'] ?? '');
if ($isbn13 === '' || !Isbn::exists($isbn13)) {
return;
}
SupplierOffer::delete_for_isbn($isbn13);
SupplierOffer::insert(
$isbn13,
SupplierOffer::SUPPLIER_BOOKSRUN,
(string) ($row['condition'] ?? 'good'),
(int) ($row['stock_qty'] ?? 0),
(float) ($row['base_price'] ?? 0.0),
(float) ($row['supplier_ship'] ?? 0.0),
(string) ($row['status'] ?? 'active'),
null // daily full-replace feed — no per-row expiry concept confirmed
);
}
/**
* THE PLACEHOLDER. Recommended real implementation once access
* exists: raw curl_init() against an ftp:// or ftps:// URL —
* wp_remote_get()/wp_remote_post() (this plugin's usual HTTP client)
* cannot speak FTP at all, and PHP's native ftp extension is NOT
* installed in docker/php/Dockerfile today. This is a deliberate,
* isolated exception to the plugin's "always use WP's HTTP API"
* convention, not a regression from it.
*
* @return array<int, array{isbn13: string, condition?: string, stock_qty?: int, base_price?: float, supplier_ship?: float, status?: string}>
*/
private static function connect_and_parse_feed(): array
{
throw new BooksRunFtpNotConfiguredException(
'BooksRun FTP bulk sync is not yet implemented: no FTP host/credentials or sample feed file ' .
'exist yet. This is a deliberate placeholder (Import\\BooksRunOfferSync::connect_and_parse_feed()) ' .
'— implement real connect+parse logic there once real access and a sample file are available.'
);
}
}
@@ -0,0 +1,91 @@
<?php
namespace Bookstore\Core\Import;
defined('ABSPATH') || exit;
/**
* Week 4: a once-a-day Action Scheduler job pulling BooksRun's FTP
* inventory feed. Registered for real now so the scheduling/dedup/
* logging plumbing is proven working via the real daily cron loop
* (docker/cron/entrypoint.sh's 60s `wp cron event run --due-now` loop) —
* the only future change needed is BooksRunOfferSync::connect_and_parse_feed()
* actually doing something.
*/
class BooksRunOfferSyncJob
{
private const HOOK = 'bsc_booksrun_daily_offer_sync';
private const GROUP = 'bsc-imports'; // distinct from Order\SupplierOrderJob's 'bsc-orders'
public static function register(): void
{
add_action(self::HOOK, [self::class, 'run']);
add_action('init', [self::class, 'ensure_scheduled']);
}
/**
* as_schedule_recurring_action() is used nowhere else in this
* plugin — no existing "avoid duplicate recurring schedules" guard to
* copy. as_has_scheduled_action() is checked first, UNGUARDED (cheap,
* no lock needed for a read) — only when it comes back false does
* this acquire a GET_LOCK and re-check-then-schedule inside it. That
* ordering matters: this runs on every single request forever once
* correctly scheduled (the steady state, essentially always), so the
* steady-state cost must be one indexed read, not a lock acquire/
* release pair — the lock only has to protect the rare window where
* the action isn't yet scheduled (fresh install, or someone manually
* unscheduled it), where two concurrent requests could otherwise both
* see "not scheduled" and both schedule.
*/
public static function ensure_scheduled(): void
{
if (!function_exists('as_has_scheduled_action') || !function_exists('as_schedule_recurring_action')) {
return;
}
if (as_has_scheduled_action(self::HOOK, [], self::GROUP)) {
return;
}
global $wpdb;
$lock_key = 'bsc_booksrun_sync_schedule_guard';
if (!$wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 1)', $lock_key))) {
return; // another concurrent request already owns this check
}
try {
if (!as_has_scheduled_action(self::HOOK, [], self::GROUP)) {
as_schedule_recurring_action(time(), DAY_IN_SECONDS, self::HOOK, [], self::GROUP);
}
} finally {
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key));
}
}
/**
* A recurring job that hard-fails every single day forever (until
* real FTP access lands) would just be noise in Action Scheduler's
* log — check is_ftp_configured() first and log one informational
* line instead of throwing. The manual CLI command
* (Cli\Commands::sync_booksrun_offers) deliberately does NOT use this
* gate — a human explicitly asking "do this now" should see exactly
* why it didn't happen, not a quiet no-op.
*/
public static function run(): void
{
if (!BooksRunOfferSync::is_ftp_configured()) {
error_log('[bookstore-core] BooksRun offer sync skipped: BOOKSRUN_FTP_HOST not configured yet.');
return;
}
try {
$result = BooksRunOfferSync::sync_all(500, null);
error_log(sprintf(
'[bookstore-core] BooksRun offer sync: %d processed, %d failed',
$result['processed'],
count($result['failed'])
));
} catch (\Throwable $e) {
error_log('[bookstore-core] BooksRun offer sync FAILED: ' . $e->getMessage());
throw $e;
}
}
}
@@ -0,0 +1,201 @@
<?php
namespace Bookstore\Core\Integration;
defined('ABSPATH') || exit;
/**
* UNVERIFIED SCHEMA WARNING: BooksRun's /api/v3/price/buy/{ISBN} response
* is documented only at a high level (nested "booksrun"/"ebook"/
* "marketplace" objects, each with priced entries) — the exact key(s) for
* a quantity/condition field within a "booksrun" entry are NOT confirmed.
* parse_booksrun_offers() below is deliberately defensive rather than
* assuming one shape; run a real call against a well-known in-print ISBN
* (this catalog's own Gutenberg ISBNs won't exist in BooksRun's inventory
* — see Week 4 plan §Verification) and correct it before trusting this in
* production.
*/
class BooksRunAdapter
{
private const ENDPOINT = 'https://booksrun.com/api/v3/price/buy/';
private const DEFAULT_DELAY_MS = 0; // checkout-path call, not a bulk crawl — see throttle() docblock
public static function is_configured(): bool
{
return self::api_key() !== null;
}
/**
* Read from the file BOOKSRUN_API_KEY_FILE points at, not a plain
* ${VAR}-style env var — same reasoning as HARDCOVER_API_TOKEN_FILE
* (see HardcoverAdapter::token()): Compose's ${VAR} interpolation
* mangles any value containing `$` followed by a letter, and a plain
* env var is also visible via `docker inspect`/`/proc/<pid>/environ`,
* unlike a 644 file inside a 700 directory.
*/
private static function api_key(): ?string
{
$file = getenv('BOOKSRUN_API_KEY_FILE');
if (!$file || !is_readable($file)) {
return null;
}
$value = trim((string) file_get_contents($file));
return $value !== '' ? $value : null;
}
/**
* Live price/stock for one ISBN, called synchronously from the
* checkout-time recheck (Order\PriceRecheck) — not a bulk operation.
* Null means "legitimately no BooksRun offer for this ISBN," which
* Order\PriceRecheck treats identically to "no sellable offer" (fail
* closed on checkout).
*
* @return array{price: float, shipping: float, stock_qty: int, condition: string}|null
*/
public static function live_price_and_stock(string $isbn13, int $delay_ms = self::DEFAULT_DELAY_MS): ?array
{
$data = self::request($isbn13, $delay_ms);
if (empty($data)) {
return null;
}
return self::parse_booksrun_offers($data);
}
/**
* Public, unlike the request-level throttling HardcoverAdapter/
* OpenLibraryAdapter apply unconditionally before every call — this
* adapter's primary caller is the synchronous checkout path, once per
* cart item, where sleeping by default is a real customer-facing
* cost that a bulk crawl doesn't have. Kept available for a future
* bulk caller to opt into explicitly.
*/
public static function throttle(int $delay_ms): void
{
usleep($delay_ms * 1000);
}
/**
* Auth is a `?key=` query param, not a header (confirmed via
* booksrun.com/page/api-reference) — unlike HardcoverAdapter's Bearer
* token. Retries network errors/5xx/429 up to 3 attempts, mirroring
* HardcoverAdapter::query()'s exact reasoning: a clean non-retryable
* 4xx is the request itself being rejected, which a retry can't fix.
*/
private static function request(string $isbn13, int $delay_ms): array
{
$key = self::api_key();
if (!$key) {
throw new \RuntimeException('BOOKSRUN_API_KEY_FILE is not set or empty');
}
if ($delay_ms > 0) {
self::throttle($delay_ms);
}
$url = self::ENDPOINT . rawurlencode($isbn13) . '?' . http_build_query(['key' => $key]);
$max_attempts = 3;
$retry_delay_ms = 500;
$last_error = 'BooksRun API request failed after retries';
for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
$response = wp_remote_get($url, ['timeout' => 20]);
if (is_wp_error($response)) {
$last_error = 'BooksRun API request failed: ' . $response->get_error_message();
} else {
$code = wp_remote_retrieve_response_code($response);
if ($code === 200) {
$body = wp_remote_retrieve_body($response);
$decoded = json_decode($body, true);
if (is_array($decoded)) {
return $decoded;
}
// BooksRun's error responses are plain strings, not JSON
// (confirmed via docs: "Wrong API access key", "Wrong
// Isbn", "Isbn not found") — a body that doesn't decode
// to an array is one of these, not a transport failure.
return self::handle_string_error((string) $body);
}
if ($code < 500 && $code !== 429) {
throw new \RuntimeException("BooksRun API returned HTTP {$code}: " . wp_remote_retrieve_body($response));
}
$last_error = "BooksRun API returned HTTP {$code}: " . wp_remote_retrieve_body($response);
}
if ($attempt < $max_attempts) {
usleep($retry_delay_ms * 1000);
}
}
throw new \RuntimeException($last_error);
}
/**
* "Wrong API access key"/"Wrong Isbn" are caller-fixable setup/data
* problems worth surfacing loudly (matches HardcoverAdapter's "config
* missing -> throw" convention); "Isbn not found" is a legitimate
* miss, not an error (matches OpenLibraryAdapter's "no cover found ->
* return null"). This three-way split isn't documented by BooksRun —
* it's inferred from how the rest of this plugin already treats
* "setup problem" vs "legitimate miss" differently.
*/
private static function handle_string_error(string $body): array
{
if (stripos($body, 'wrong api access key') !== false) {
throw new \RuntimeException('BooksRun API key rejected: ' . $body);
}
if (stripos($body, 'wrong isbn') !== false) {
throw new \RuntimeException('BooksRun rejected ISBN format: ' . $body);
}
if (stripos($body, 'isbn not found') !== false) {
return [];
}
throw new \RuntimeException('BooksRun API returned an unrecognized non-JSON response: ' . substr($body, 0, 200));
}
/**
* Only the "booksrun" category (their own direct inventory) is
* considered — deliberately excluding "marketplace" (arbitrary
* third-party sellers with no supplier_code of their own in this
* schema) and "ebook" (not a physical resellable book). See class
* docblock for the unverified-schema caveat on quantity/condition.
*/
private static function parse_booksrun_offers(array $data): ?array
{
$category = $data['booksrun'] ?? null;
if (!is_array($category) || empty($category)) {
return null;
}
$shipping = isset($category['shipping']) && is_numeric($category['shipping'])
? (float) $category['shipping']
: 0.0;
// Entries may be a flat list under 'booksrun' itself, or nested
// under a sub-key like 'offers'/'items' — genuinely unknown until
// a real call is made. Check both shapes defensively.
$entries = $category['offers'] ?? $category['items'] ?? $category;
$best_price = null;
foreach ($entries as $entry) {
if (!is_array($entry) || !isset($entry['price']) || !is_numeric($entry['price'])) {
continue;
}
$price = (float) $entry['price'];
if ($best_price === null || $price < $best_price) {
$best_price = $price;
}
}
if ($best_price === null) {
return null;
}
return [
'price' => $best_price,
'shipping' => $shipping,
'stock_qty' => 1, // placeholder "buyable" signal — no quantity field confirmed, see class docblock
'condition' => 'good', // placeholder — no condition field confirmed, see class docblock
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace Bookstore\Core\Membership;
defined('ABSPATH') || exit;
/**
* Design doc §07 extension: a stepped, sitewide promo — $5 off per $50
* spent, capping at $200 spend for a max $20 off (evolved from an
* earlier flat "$10 off $100+, members only" version). Applied via
* WC_Cart::add_fee (a negative fee), driven entirely by the current
* customer's `step_discount` tier benefit — same shape as
* Shipping\BSC_Free_Shipping's threshold, just a fee instead of a
* shipping rate. Business case: BooksRun charges a flat per-order
* shipping fee regardless of order size, so bundling a customer's
* purchases into one larger order rather than several smaller ones
* saves that fee outright — more than covering the discount.
*
* "Sitewide" isn't a separate concept from the member-tier system — the
* `guest` tier (the universal fallback for every non-member customer;
* see Membership\Tier::tier_for_user()) carries the same `step_discount`
* benefit as `free_member` by default, so every customer gets it unless
* an admin unchecks it on one tier or the other via the settings screen
* (WooCommerce > Settings > Membership Tiers) — e.g. unchecking it only
* on `guest` turns this into a members-only promo without touching any
* code.
*
* Runs on woocommerce_cart_calculate_fees, which fires well before
* Order\CheckoutRecheck's hooks and bakes the fee into cart/order totals
* before CheckoutRecheck ever runs — confirmed non-conflicting:
* CheckoutRecheck only re-validates per-line supplier pricing, it never
* inspects or overwrites fees.
*/
class MemberDiscount
{
public static function register(): void
{
add_action('woocommerce_cart_calculate_fees', [self::class, 'apply']);
}
public static function apply(\WC_Cart $cart): void
{
$user_id = get_current_user_id();
$config = Tier::benefit_config_for_user($user_id, 'step_discount');
if (empty($config['step_amount']) || empty($config['discount_per_step']) || empty($config['max_spend'])) {
return;
}
$step_amount = (float) $config['step_amount'];
$subtotal = (float) $cart->get_subtotal();
$steps = min(
floor($subtotal / $step_amount),
floor((float) $config['max_spend'] / $step_amount)
);
$discount = $steps * (float) $config['discount_per_step'];
if ($discount > 0) {
$cart->add_fee('Member discount', -1 * $discount, false);
}
}
}
@@ -0,0 +1,52 @@
<?php
namespace Bookstore\Core\Membership;
defined('ABSPATH') || exit;
/**
* Design doc §07: "free membership... verified member/account flag plus
* transparent marketing-email consent/preferences." Built on WooCommerce's
* own My Account registration (enabled via woocommerce_enable_myaccount_
* registration in deploy.sh) rather than a bespoke signup form — the
* standard WC extension points for adding a custom registration field
* (woocommerce_register_form to render it, woocommerce_created_customer to
* persist it, firing only after WC's own registration nonce/validation has
* already passed) are the official way to do this, so nothing homegrown
* exists here beyond the consent checkbox itself and assigning the new
* account to the free_member tier.
*/
class Registration
{
public static function register(): void
{
add_action('woocommerce_register_form', [self::class, 'render_consent_field']);
add_action('woocommerce_created_customer', [self::class, 'on_customer_created'], 10, 1);
}
public static function render_consent_field(): void
{
?>
<p class="form-row form-row-wide">
<label for="bsc_marketing_consent">
<input type="checkbox" class="woocommerce-Input woocommerce-Input--checkbox input-checkbox" name="bsc_marketing_consent" id="bsc_marketing_consent" value="1" />
<?php esc_html_e('Sign me up for free membership (free shipping at $15, plus member-only order discounts) and occasional email updates.', 'bookstore-core'); ?>
</label>
</p>
<?php
}
public static function on_customer_created(int $customer_id): void
{
$consent = !empty($_POST['bsc_marketing_consent']) ? 1 : 0;
$tier = Tier::find_by_slug(Tier::FREE_MEMBER_SLUG);
global $wpdb;
$wpdb->insert($wpdb->prefix . 'bsc_member', [
'user_id' => $customer_id,
'tier_id' => $tier['tier_id'] ?? null,
'marketing_consent' => $consent,
'consent_captured_at' => current_time('mysql'),
], ['%d', '%d', '%d', '%s']);
}
}
@@ -0,0 +1,226 @@
<?php
namespace Bookstore\Core\Membership;
defined('ABSPATH') || exit;
/**
* Design doc §07's single is_free_member flag became a real tier system:
* bsc_tier holds an arbitrary JSON `benefits` map per tier (see
* Schema.php), so adding a tier, or changing an existing tier's benefit
* *values*, is a pure data change — no migration, no deploy. Adding a
* wholly new benefit *type* (one nothing currently checks for) still
* needs a small class at the point that enforces it, same as
* Shipping\BSC_Free_Shipping/MemberDiscount are — this is the single
* source of truth both read from, replacing BSC_Free_Shipping's old
* private duplicated is_free_member() query.
*/
class Tier
{
public const GUEST_SLUG = 'guest';
public const FREE_MEMBER_SLUG = 'free_member';
/** @return array<int, array{tier_id:int, slug:string, name:string, sort_order:int, benefits:array}> */
public static function all(): array
{
global $wpdb;
$rows = $wpdb->get_results(
"SELECT tier_id, slug, name, sort_order, benefits FROM {$wpdb->prefix}bsc_tier ORDER BY sort_order ASC",
ARRAY_A
);
return array_map([self::class, 'hydrate'], $rows ?: []);
}
public static function find(int $tier_id): ?array
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT tier_id, slug, name, sort_order, benefits FROM {$wpdb->prefix}bsc_tier WHERE tier_id = %d",
$tier_id
), ARRAY_A);
return $row ? self::hydrate($row) : null;
}
public static function find_by_slug(string $slug): ?array
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT tier_id, slug, name, sort_order, benefits FROM {$wpdb->prefix}bsc_tier WHERE slug = %s",
$slug
), ARRAY_A);
return $row ? self::hydrate($row) : null;
}
public static function create(string $slug, string $name, int $sort_order = 0, array $benefits = []): int
{
global $wpdb;
$wpdb->insert($wpdb->prefix . 'bsc_tier', [
'slug' => $slug,
'name' => $name,
'sort_order' => $sort_order,
'benefits' => wp_json_encode($benefits),
], ['%s', '%s', '%d', '%s']);
return (int) $wpdb->insert_id;
}
public static function update_benefits(string $slug, array $benefits): bool
{
global $wpdb;
$updated = $wpdb->update(
$wpdb->prefix . 'bsc_tier',
['benefits' => wp_json_encode($benefits)],
['slug' => $slug],
['%s'],
['%s']
);
return $updated !== false;
}
/** Full-row update (name/sort_order/benefits at once) — what the admin settings screen needs; the CLI's finer-grained set_benefit()/remove_benefit() below stay slug-addressed for convenience. */
public static function update(int $tier_id, string $name, int $sort_order, array $benefits): bool
{
global $wpdb;
$updated = $wpdb->update(
$wpdb->prefix . 'bsc_tier',
['name' => $name, 'sort_order' => $sort_order, 'benefits' => wp_json_encode($benefits)],
['tier_id' => $tier_id],
['%s', '%d', '%s'],
['%d']
);
return $updated !== false;
}
public static function set_benefit(string $slug, string $type, array $config): bool
{
$tier = self::find_by_slug($slug);
if (!$tier) {
return false;
}
$benefits = $tier['benefits'];
$benefits[$type] = $config;
return self::update_benefits($slug, $benefits);
}
public static function remove_benefit(string $slug, string $type): bool
{
$tier = self::find_by_slug($slug);
if (!$tier) {
return false;
}
$benefits = $tier['benefits'];
unset($benefits[$type]);
return self::update_benefits($slug, $benefits);
}
/**
* Refuses to delete the `guest` tier (the universal fallback every
* lookup resolves to) or a tier any bsc_member row still points at —
* returns a human-readable reason string on refusal, true on success.
*/
public static function delete(string $slug): bool|string
{
if ($slug === self::GUEST_SLUG) {
return 'cannot delete the guest tier — it is the universal fallback';
}
$tier = self::find_by_slug($slug);
if (!$tier) {
return 'no such tier';
}
global $wpdb;
$member_count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}bsc_member WHERE tier_id = %d",
$tier['tier_id']
));
if ($member_count > 0) {
return "tier still has {$member_count} member(s) assigned — reassign them first";
}
$wpdb->delete($wpdb->prefix . 'bsc_tier', ['tier_id' => $tier['tier_id']], ['%d']);
return true;
}
public static function assign_member(int $user_id, string $slug): bool
{
$tier = self::find_by_slug($slug);
if (!$tier) {
return false;
}
global $wpdb;
$exists = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
$user_id
));
if ($exists) {
$wpdb->update($wpdb->prefix . 'bsc_member', ['tier_id' => $tier['tier_id']], ['user_id' => $user_id], ['%d'], ['%d']);
} else {
$wpdb->insert($wpdb->prefix . 'bsc_member', [
'user_id' => $user_id,
'tier_id' => $tier['tier_id'],
], ['%d', '%d']);
}
return true;
}
/**
* The one lookup Shipping\BSC_Free_Shipping and MemberDiscount both
* need: resolve the user's tier (falling back to `guest` if they
* have no bsc_member row, or their tier_id doesn't resolve), then
* pull one benefit type's config out of it. Null if that tier has
* no such benefit configured.
*/
public static function benefit_config_for_user(int $user_id, string $type): ?array
{
$tier = self::tier_for_user($user_id);
if (!$tier) {
return null;
}
return $tier['benefits'][$type] ?? null;
}
public static function tier_for_user(int $user_id): ?array
{
global $wpdb;
$tier_id = $user_id
? (int) $wpdb->get_var($wpdb->prepare(
"SELECT tier_id FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
$user_id
))
: 0;
if ($tier_id) {
$tier = self::find($tier_id);
if ($tier) {
return $tier;
}
}
return self::find_by_slug(self::GUEST_SLUG);
}
/**
* Seeds guest/free_member with today's real defaults — only if
* missing by slug, so a human's later edits via the CLI/settings
* screen are never clobbered on a later plugins_loaded (same
* never-overwrite convention used by other environment-seeded settings).
*/
public static function seed_defaults(): void
{
if (!self::find_by_slug(self::GUEST_SLUG)) {
self::create(self::GUEST_SLUG, 'Guest', 0, [
'free_shipping_threshold' => ['threshold' => 25.00],
'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200],
]);
}
if (!self::find_by_slug(self::FREE_MEMBER_SLUG)) {
self::create(self::FREE_MEMBER_SLUG, 'Free Member', 10, [
'free_shipping_threshold' => ['threshold' => 15.00],
'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200],
]);
}
}
private static function hydrate(array $row): array
{
$row['tier_id'] = (int) $row['tier_id'];
$row['sort_order'] = (int) $row['sort_order'];
$row['benefits'] = $row['benefits'] ? (json_decode($row['benefits'], true) ?: []) : [];
return $row;
}
}
@@ -0,0 +1,102 @@
<?php
namespace Bookstore\Core\Order;
defined('ABSPATH') || exit;
/**
* bsc_order_audit: one row per order line, written once at checkout
* (CheckoutRecheck::run_checks()) and updated once — either on refund
* (record_refund(), a post-capture reversal) or after a confirmed void
* (record_void()). Requesting cancellation alone is not confirmation: the
* Stripe status-change hook exposes no success result, so callers must
* reconcile the order note/dashboard before recording a void. Style mirrors
* Catalog\SupplierOffer exactly.
*/
class AuditTrail
{
public static function table(): string
{
global $wpdb;
return $wpdb->prefix . 'bsc_order_audit';
}
public static function insert(
int $order_id,
?int $order_item_id,
int $work_id,
int $offer_id,
string $supplier_code,
float $supplier_cost,
float $supplier_shipping,
float $processor_cost_expected,
float $selling_price,
float $expected_contribution
): int {
global $wpdb;
$result = $wpdb->insert(self::table(), [
'order_id' => $order_id,
'order_item_id' => $order_item_id,
'work_id' => $work_id,
'offer_id' => $offer_id,
'supplier_code' => $supplier_code,
'supplier_cost' => $supplier_cost,
'supplier_shipping' => $supplier_shipping,
'processor_cost_expected' => $processor_cost_expected,
'selling_price' => $selling_price,
'expected_contribution' => $expected_contribution,
'created_at' => current_time('mysql', true),
]);
if ($result === false) {
throw new \RuntimeException('bsc_order_audit insert failed: ' . $wpdb->last_error . " (order_id: {$order_id})");
}
return (int) $wpdb->insert_id;
}
/** @return object[] */
public static function for_order(int $order_id): array
{
global $wpdb;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM " . self::table() . " WHERE order_id = %d",
$order_id
));
}
/**
* A full refund (Appendix B: "the store loses only the processing
* fee") recovers nothing else — record that as the only real loss
* against the expectation this row already captured at checkout time.
* Only reachable now for a refund *after* a real capture (e.g. a
* manual admin refund post-fulfillment) — a failed supplier order
* before capture uses record_void() only after Stripe confirms release.
*/
public static function record_refund(int $order_id): void
{
global $wpdb;
$wpdb->query($wpdb->prepare(
"UPDATE " . self::table() . "
SET processor_cost_actual = processor_cost_expected,
actual_contribution = -processor_cost_expected
WHERE order_id = %d",
$order_id
));
}
/**
* A confirmed pre-capture authorization release is genuinely zero cost,
* unlike the processing-fee loss record_refund() models. Do not call this
* merely because cancellation was requested.
*/
public static function record_void(int $order_id): void
{
global $wpdb;
$wpdb->query($wpdb->prepare(
"UPDATE " . self::table() . "
SET processor_cost_actual = 0,
actual_contribution = 0
WHERE order_id = %d",
$order_id
));
}
}
@@ -0,0 +1,241 @@
<?php
namespace Bookstore\Core\Order;
use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit;
/**
* Appendix B (revised for the preauth-then-capture rework): "Supplier
* stock, price, and margin are rechecked live, synchronously,
* immediately before the customer's card is authorized (held)." Fraud
* review runs after Stripe places the order on hold. This class owns the
* pre-payment price and margin validation only.
*
* Two checkout implementations, two different hook sets — confirmed by
* reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
* directly, not assumed:
*
* - Classic [woocommerce_checkout] shortcode: woocommerce_after_checkout_
* validation fires before any order row exists (a failure there blocks
* checkout with zero DB residue); woocommerce_checkout_order_processed
* fires after the order exists but before process_payment().
* - WooCommerce Blocks / Store API (what this site's storefront actually
* uses — verified live, the shop only serves Cart/Checkout blocks, no
* shortcode checkout page exists): a draft order already exists by the
* time the customer reaches checkout, so there's no "before any order
* row exists" phase at all. The one relevant hook is
* woocommerce_checkout_validate_order_before_payment ($order,
* \WP_Error $validation_errors), fired immediately before
* process_payment() — adding an error there throws a RouteException
* that aborts checkout before any charge is attempted.
*
* Both are wired below so the recheck runs regardless of which checkout
* implementation is actually rendering the page; run_checks() holds the
* one real implementation shared by both.
*/
class CheckoutRecheck
{
/**
* Design doc §05: "A small price cushion is acceptable, but stale
* supplier cost must never be trusted as final." A judgment call on
* the actual cushion amount — not specified in the plan — chosen as a
* flat $0.50 rather than a percentage so a cheap paperback and an
* expensive hardcover aren't held to wildly different tolerances.
*/
private const PRICE_CUSHION = 0.50;
public static function register(): void
{
add_action('woocommerce_after_checkout_validation', [self::class, 'validate_cart'], 10, 2);
add_action('woocommerce_checkout_order_processed', [self::class, 'finalize_order'], 10, 3);
add_action('woocommerce_checkout_validate_order_before_payment', [self::class, 'validate_order_before_payment'], 10, 2);
}
/** Classic checkout only — cart-level pass, before any order exists. */
public static function validate_cart(array $data, \WP_Error $errors): void
{
if (!WC()->cart || WC()->cart->is_empty()) {
return;
}
if (!self::acquire_lock()) {
$errors->add(
'bsc-recheck-lock',
'Your previous checkout attempt for this cart is still processing — please wait a moment and try again.'
);
return;
}
$failed = false;
foreach (WC()->cart->get_cart() as $cart_item) {
$work_id = PriceRecheck::work_id_for_product((int) $cart_item['product_id']);
if (!$work_id) {
continue; // not a bookstore-core-managed product — nothing to recheck
}
$recheck = PriceRecheck::recheck_for_work($work_id);
$quoted = (float) $cart_item['data']->get_price();
if (!$recheck || $recheck->price > $quoted + self::PRICE_CUSHION) {
$errors->add(
'bsc-recheck',
sprintf(
'Sorry, "%s" is no longer available at its listed price. Please refresh your cart and try again.',
$cart_item['data']->get_name()
)
);
$failed = true;
}
}
if ($failed) {
// finalize_order() will never fire for this request — this hook
// is the only place that can release the lock.
self::release_lock();
}
// On success the lock is deliberately left held: finalize_order()
// (the next hook in the same request, classic flow only) releases
// it in a finally block, whether it passes or throws.
}
/** Classic checkout only — order exists, runs right before payment. */
public static function finalize_order(int $order_id, array $posted_data, \WC_Order $order): void
{
try {
$failure = self::run_checks($order);
if ($failure !== null) {
throw new \Exception($failure);
}
} finally {
self::release_lock();
}
}
/**
* WooCommerce Blocks / Store API checkout — the path this site's real
* storefront actually runs. Self-contained: acquires and releases its
* own lock in one call, since Blocks checkout has no earlier hook to
* split the lock across the way the classic pair above does.
*
* KNOWN LIMITATION, confirmed by firing two genuinely concurrent
* checkout POSTs at the same session in testing: Store API creates an
* independent draft order per request rather than one shared draft
* order per cart, so two overlapping submissions can each pass this
* hook on their OWN order object and both go on to charge — this lock
* only serializes the recheck itself (so two requests can't both read
* a fresh-but-about-to-be-stale offer at the same instant), it does
* NOT collapse two overlapping submissions of the same cart into one
* order. Closing that fully would need a client-supplied idempotency
* key checked at order-creation time (the standard fix for this class
* of problem), which touches the checkout block's frontend — out of
* scope here. What this DOES fully solve, proven directly: duplicate
* *supplier-submission* execution (SupplierOrderJob's own meta+lock
* guards — see that class), which is what the design doc's "duplicate
* webhook/job... do not double-order" requirement is actually about.
*
*/
public static function validate_order_before_payment(\WC_Order $order, \WP_Error $validation_errors): void
{
if (!self::acquire_lock()) {
$validation_errors->add(
'bsc-recheck-lock',
'Your previous checkout attempt for this cart is still processing — please wait a moment and try again.'
);
return;
}
try {
$failure = self::run_checks($order);
if ($failure !== null) {
$validation_errors->add('bsc-recheck', $failure);
}
} finally {
self::release_lock();
}
}
/**
* Shared by both checkout implementations. Returns null on success
* (audit rows already written) or a customer-facing failure message
* (order already routed to the exception status) on failure. Audit rows
* are only ever inserted after every
* item has passed — never partially, which matters because a failed
* attempt can be resubmitted against the same draft order under
* Blocks checkout.
*/
private static function run_checks(\WC_Order $order): ?string
{
$pending_audit_rows = [];
foreach ($order->get_items() as $item) {
$work_id = PriceRecheck::work_id_for_product($item->get_product_id());
if (!$work_id) {
continue;
}
$recheck = PriceRecheck::recheck_for_work($work_id);
$quantity = max(1, $item->get_quantity());
$quoted_unit = (float) $item->get_total() / $quantity;
if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) {
self::route_to_exception($order, sprintf(
'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).',
$item->get_name(),
$quoted_unit,
$recheck->price ?? 0.0
));
return 'Sorry, part of your order is no longer available at its listed price. Please try again.';
}
$offer = $recheck->offer;
$selling_price = (float) $item->get_total();
$supplier_cost = (float) $offer->base_price * $quantity;
$supplier_shipping = (float) $offer->supplier_ship * $quantity;
$processor_cost_expected = PricingEngine::estimate_processing_cost($selling_price);
$pending_audit_rows[] = [
$order->get_id(),
$item->get_id(),
$work_id,
(int) $offer->offer_id,
(string) $offer->supplier_code,
$supplier_cost,
$supplier_shipping,
$processor_cost_expected,
$selling_price,
$selling_price - $supplier_cost - $supplier_shipping - $processor_cost_expected,
];
}
foreach ($pending_audit_rows as $row) {
AuditTrail::insert(...$row);
}
return null;
}
private static function route_to_exception(\WC_Order $order, string $reason): void
{
$order->update_status(OrderStatuses::EXCEPTION, $reason);
}
private static function lock_key(): string
{
$identity = WC()->session ? WC()->session->get_customer_id() : (string) session_id();
return 'bsc_checkout_' . md5((string) $identity);
}
private static function acquire_lock(): bool
{
global $wpdb;
return (bool) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', self::lock_key()));
}
private static function release_lock(): void
{
global $wpdb;
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', self::lock_key()));
}
}
@@ -0,0 +1,124 @@
<?php
namespace Bookstore\Core\Order;
defined('ABSPATH') || exit;
/**
* Design doc §04/Appendix B state machine, revised again for the
* preauth-then-capture rework: Pending -> Margin Verify -> Authorized
* (Held) -> Fraud Check -> Supplier Ordered -> Charged -> Shipped ->
* Complete. Pending/Charged/Complete map onto WooCommerce's own
* pending/processing/completed. Margin Verify still completes within one
* request (CheckoutRecheck) and is logged as an order note rather than a
* full status transition. Authorized IS a real persisted status now —
* unlike the single-request sub-steps, it can legitimately sit for a
* while (fraud review, the supplier-order Action Scheduler job) with
* real money on hold, so it needs to be visible/filterable in wp-admin,
* not just logged. Same reasoning already covered bsc-exception
* ("every failure path must end in a visible review state") and the two
* forward-progress steps (bsc-ordered, bsc-shipped).
*
* register_post_status()/the wc_order_statuses filter is the standard
* WooCommerce recipe for adding a custom order status and is unaffected
* by HPOS — HPOS only changes where an order's data is stored, not how
* the status registry itself works.
*/
class OrderStatuses
{
public const EXCEPTION = 'bsc-exception';
// "wc-" + slug must fit post_status/wp_wc_orders.status's VARCHAR(20) —
// confirmed the hard way: 'wc-bsc-supplier-ordered' (23 chars) silently
// truncated to 'wc-bsc-supplier-orde' in this DB's non-strict sql_mode,
// which get_status() then displayed with its 'wc-' prefix stripped as
// the nonsensical 'bsc-supplier-orde'. 'wc-bsc-ordered' is 14 chars.
public const SUPPLIER_ORDERED = 'bsc-ordered';
public const SHIPPED = 'bsc-shipped';
// Reintroduced by the preauth-then-capture rework (design doc
// Appendix B, revised again) — sits between Pending and (native)
// Processing: the card is held, not yet charged. 'wc-bsc-authorized'
// is 17 chars, fits the same VARCHAR(20) constraint above.
public const AUTHORIZED = 'bsc-authorized';
public static function register(): void
{
add_action('init', [self::class, 'register_post_statuses']);
add_filter('wc_order_statuses', [self::class, 'add_to_order_statuses']);
}
public static function register_post_statuses(): void
{
register_post_status('wc-' . self::AUTHORIZED, [
'label' => 'Authorized (Held)',
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => self::noop_label_count('Authorized (Held)'),
]);
register_post_status('wc-' . self::EXCEPTION, [
'label' => 'Needs Review',
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => self::noop_label_count('Needs Review'),
]);
register_post_status('wc-' . self::SUPPLIER_ORDERED, [
'label' => 'Supplier Ordered',
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => self::noop_label_count('Supplier Ordered'),
]);
register_post_status('wc-' . self::SHIPPED, [
'label' => 'Shipped',
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => self::noop_label_count('Shipped'),
]);
}
public static function add_to_order_statuses(array $statuses): array
{
$with_new = [];
foreach ($statuses as $key => $label) {
$with_new[$key] = $label;
if ($key === 'wc-pending') {
$with_new['wc-' . self::AUTHORIZED] = 'Authorized (Held)';
}
if ($key === 'wc-processing') {
$with_new['wc-' . self::SUPPLIER_ORDERED] = 'Supplier Ordered';
$with_new['wc-' . self::SHIPPED] = 'Shipped';
}
}
$with_new['wc-' . self::EXCEPTION] = 'Needs Review';
return $with_new;
}
/**
* register_post_status()'s 'label_count' expects the array shape
* _n_noop() produces (singular/plural/context/domain) — built directly
* rather than via _n_noop() since this plugin doesn't ship a text
* domain/.pot file anywhere else either (matches the rest of the
* codebase, which uses plain strings throughout).
*/
private static function noop_label_count(string $label): array
{
$markup = $label . ' <span class="count">(%s)</span>';
return [
0 => $markup,
1 => $markup,
'singular' => $markup,
'plural' => $markup,
'context' => null,
'domain' => null,
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace Bookstore\Core\Order;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Integration\BooksRunAdapter;
use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit;
/**
* Appendix B: supplier price/stock/margin rechecked live, synchronously,
* immediately before charging. Sibling to PricingEngine::price_for_work()
* that also surfaces which offer won — price_for_work() only ever returns
* a float, but the checkout recheck needs to pin the exact offer_id (for
* Order\AuditTrail and, eventually, Week 4's supplier-submission
* idempotency) rather than re-resolving best_offer_for_isbns() a third
* time and risking a different answer if the clock/stock moved between
* calls.
*/
class PriceRecheck
{
/**
* Null if the work no longer has any sellable offer at all — the
* caller treats that identically to "margin recheck failed": cancel
* before payment is taken.
*/
public static function recheck_for_work(int $work_id): ?object
{
$isbns = Isbn::isbns_for_work($work_id);
$offer = SupplierOffer::best_offer_for_isbns($isbns);
if (!$offer) {
return null;
}
if ($offer->supplier_code === SupplierOffer::SUPPLIER_BOOKSRUN) {
$offer = self::apply_live_booksrun_check($offer);
if (!$offer) {
return null;
}
}
return (object) [
'offer' => $offer,
'price' => PricingEngine::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship),
];
}
/**
* Week 4 (Appendix B: "stale supplier cost must never be trusted as
* final"): our own bsc_supplier_offer row for a BooksRun-sourced offer
* is only as fresh as the last daily FTP sync — up to a day stale.
* Fail CLOSED on any live-check problem (adapter throws, or BooksRun
* reports no stock): treat it identically to "no sellable offer,"
* which recheck_for_work()'s caller (Order\CheckoutRecheck) already
* handles today with zero changes needed there. This gates real money
* movement — the safe default when freshness can't be confirmed is to
* refuse the sale, not quote a price we already know might be wrong.
*
* On success, the live values are written back into the SAME
* offer_id row (SupplierOffer::update_live_snapshot()) rather than
* only returned in-memory — see that method's docblock for why that
* matters at fulfillment time.
*/
private static function apply_live_booksrun_check(object $offer): ?object
{
try {
$live = BooksRunAdapter::live_price_and_stock($offer->isbn13);
} catch (\Throwable $e) {
return null;
}
if (!$live || $live['stock_qty'] <= 0) {
try {
SupplierOffer::update_live_snapshot((int) $offer->offer_id, 0, (float) $offer->base_price, (float) $offer->supplier_ship, 'stale');
} catch (\Throwable $e) {
// best-effort — the checkout-blocking decision below doesn't depend on this write succeeding
}
return null;
}
try {
SupplierOffer::update_live_snapshot(
(int) $offer->offer_id,
$live['stock_qty'],
$live['price'],
$live['shipping'],
'active'
);
} catch (\Throwable $e) {
return null; // couldn't persist the live values — fail closed rather than quote a price we didn't actually save
}
$offer->stock_qty = $live['stock_qty'];
$offer->base_price = $live['price'];
$offer->supplier_ship = $live['shipping'];
$offer->condition = $live['condition'];
return $offer;
}
/**
* ProductSync stamps every synced product with its owning Work's id
* (Product\ProductSync::sync_one()) — reused here rather than adding a
* second work-for-product lookup query.
*/
public static function work_id_for_product(int $product_id): ?int
{
$work_id = get_post_meta($product_id, '_bsc_work_id', true);
return $work_id !== '' ? (int) $work_id : null;
}
}
@@ -0,0 +1,184 @@
<?php
namespace Bookstore\Core\Order;
use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Payments\FraudCheck;
defined('ABSPATH') || exit;
/**
* Design doc §04/§06: "Supplier Ordered" — submitting the order to the
* actual supplier. Routed through Action Scheduler now, even against the
* synthetic gutenberg_test supplier which has no real API to be
* slow/flaky against, so Week 4's BooksRun/Ingram integration only swaps
* this class's run() body for a real HTTP call — the scheduling, locking,
* status transition, and exception-routing plumbing is already correct
* and untouched. Also where the plan doc's "BooksRun/API timeout -> retry
* safely via queue; do not double-order" idempotency requirement actually
* lives, ahead of Week 4 needing it for real.
*
* The official WooCommerce Stripe gateway creates an On hold order when
* manual capture is enabled. We fraud-check that authorized order, move it
* into the bookstore's Authorized state, and submit it to the supplier.
* Moving a successful order to Processing asks the Stripe plugin to capture
* the authorization; cancelling a failed one asks Stripe to release it.
*/
class SupplierOrderJob
{
public static function register(): void
{
// Stripe fires this only after it has persisted the transaction ID,
// captured flag, and On hold status. The generic On hold transition
// fires too early inside Stripe's process_response() and can race the
// save of `_stripe_charge_captured=no`.
add_action('wc_gateway_stripe_process_response', [self::class, 'accept_stripe_authorization'], 10, 2);
add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']);
add_action('bsc_submit_supplier_order', [self::class, 'run']);
}
/** Promote only manually authorized orders from the official Stripe gateway. */
public static function accept_stripe_authorization(object $response, \WC_Order $order): void
{
if (
!$order->has_status('on-hold')
|| !self::is_stripe_order($order)
|| $order->get_meta('_stripe_charge_captured') !== 'no'
) {
return;
}
$fraud = FraudCheck::run($order);
if (!$fraud['pass']) {
self::fail_and_release($order, 'Fraud check failed: ' . $fraud['reason']);
return;
}
$order->update_status(OrderStatuses::AUTHORIZED, 'Stripe authorized the payment; awaiting supplier confirmation.');
}
/**
* WooCommerce's per-status transition hooks can fire more than once
* for the same order (e.g. a status re-saved without actually
* changing) — this guard (checked again, more strongly, inside run()
* itself) is what stops a duplicate firing from scheduling the
* submission twice.
*/
public static function schedule_for_order(int $order_id): void
{
$order = wc_get_order($order_id);
if (!$order || $order->get_meta('_bsc_supplier_order_key')) {
return;
}
$order->update_meta_data('_bsc_supplier_order_key', wp_generate_uuid4());
$order->save();
as_schedule_single_action(time(), 'bsc_submit_supplier_order', ['order_id' => $order_id], 'bsc-orders');
}
public static function run(int $order_id): void
{
global $wpdb;
$lock_key = 'bsc_supplier_order_' . $order_id;
// Belt-and-suspenders over schedule_for_order()'s meta guard: covers
// two Action Scheduler rows somehow both getting scheduled, or AS's
// own retry of a stalled action racing a still-running attempt.
if (!$wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 10)', $lock_key))) {
return;
}
try {
$order = wc_get_order($order_id);
if (!$order || $order->get_meta('_bsc_supplier_order_status') === 'submitted') {
return;
}
foreach (AuditTrail::for_order($order_id) as $audit_row) {
$offer = $wpdb->get_row($wpdb->prepare(
"SELECT 1 FROM " . SupplierOffer::table() . "
WHERE offer_id = %d AND status = 'active' AND stock_qty > 0",
$audit_row->offer_id
));
if (!$offer) {
$order->update_meta_data('_bsc_supplier_order_status', 'failed');
$order->save();
self::fail_and_release(
$order,
"Pinned supplier offer #{$audit_row->offer_id} is no longer sellable at fulfillment time."
);
return;
}
}
$order->add_order_note('Supplier order submitted (synthetic: gutenberg_test).');
$order->update_meta_data('_bsc_supplier_order_status', 'submitted');
$order->save();
self::capture_and_complete($order);
} finally {
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key));
}
}
/**
* The supplier order just succeeded — this is the first point the card
* is actually charged. The official Stripe plugin captures when an
* on-hold/manual-capture order transitions to Processing.
*
* Capture failing here (card expired/declined during the hold window
* — rare, but real) is its own distinct failure mode from a supplier-
* side problem: the supplier submission already (synthetically, for
* now) succeeded, so this can't cleanly fall back to fail_and_release()
* the way an unsellable offer can. Routes to exception with an
* explicit "needs manual follow-up" note instead — the same manual-
* fallback philosophy Appendix B already accepted for one-off
* unfulfillable orders.
*/
private static function capture_and_complete(\WC_Order $order): void
{
$order->update_status('processing', 'Supplier confirmed; requesting Stripe capture.');
// Stripe's capture hook is synchronous and persists this flag. Reload
// so a failed capture cannot be mistaken for a successful transition.
$order = wc_get_order($order->get_id());
if (!$order || $order->get_meta('_stripe_charge_captured') !== 'yes') {
if ($order) {
$order->update_status(
OrderStatuses::EXCEPTION,
'Supplier order succeeded but Stripe capture was not confirmed — needs manual follow-up; do not resubmit to the supplier.'
);
}
return;
}
$order->update_status(OrderStatuses::SUPPLIER_ORDERED, 'Stripe payment captured.');
}
/**
* Nothing has been charged yet. The Stripe extension releases an
* uncaptured authorization when the order becomes Cancelled.
*/
private static function fail_and_release(\WC_Order $order, string $reason): void
{
$order->update_meta_data('_bsc_stripe_release_requested', 'yes');
$order->save();
$order->update_status('cancelled', $reason . ' Releasing the Stripe authorization.');
// Stripe's cancelled-status handler performs the API call but exposes
// no success result to third-party callbacks. Do not record a
// confirmed zero-cost void here; a failed API call must remain visible
// for reconciliation instead of being reported as successful.
$order->update_status(
OrderStatuses::EXCEPTION,
'Stripe authorization release requested; verify the Stripe order note or dashboard before closing review.'
);
}
private static function is_stripe_order(\WC_Order $order): bool
{
$method = $order->get_payment_method();
return $method === 'stripe' || str_starts_with($method, 'stripe_');
}
}
@@ -0,0 +1,34 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* Design doc §05 local WooCommerce fraud-check extension point. It is a
* no-op by default for Week 3
* — the user has decided not to build custom fraud heuristics now, only
* the extension point an optional paid plugin (Shop Defender) plugs into
* later via add_filter('bsc_fraud_check_result', ...). The step still
* runs and is logged on every order, unconditionally, so "Fraud Check" is
* a real, auditable stage in the state machine rather than an absent one.
* Called after Stripe authorizes the card and places the order on hold,
* before the order proceeds to supplier submission.
*/
class FraudCheck
{
/** @return array{pass: bool, reason: string} */
public static function run(\WC_Order $order): array
{
$result = apply_filters('bsc_fraud_check_result', [
'pass' => true,
'reason' => 'no-op: fraud plugin not installed',
], $order);
$order->add_order_note(
'Fraud check: ' . ($result['pass'] ? 'passed' : 'FAILED') . ' — ' . $result['reason']
);
return $result;
}
}
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
/**
* Design doc §05: Retail Price = (Supplier Base + Supplier Shipping +
* Fixed Processing) / (1 - Processing Rate - Target Margin). Processing
* Rate is a placeholder blended estimate — tune from real Helcim
* Rate is a placeholder blended estimate — tune from real Stripe
* settlement data once it exists, not from guessing.
*/
class PricingEngine
@@ -56,4 +56,18 @@ class PricingEngine
}
return self::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship);
}
/**
* Week 3: what calculate_retail_price() assumed Stripe's cut would be
* for a given selling price, for the order-audit ledger
* (Order\AuditTrail) to compare against actual settlement later.
* Exposed as its own method (rather than inlining the formula at every
* call site) so there is exactly one place PROCESSING_RATE/
* FIXED_PROCESSING get read once real settlement data replaces the
* placeholder — see this class's docblock.
*/
public static function estimate_processing_cost(float $selling_price): float
{
return round($selling_price * self::PROCESSING_RATE + self::FIXED_PROCESSING, 2);
}
}
@@ -2,6 +2,8 @@
namespace Bookstore\Core;
use Bookstore\Core\Membership\Tier;
defined('ABSPATH') || exit;
/**
@@ -83,12 +85,62 @@ class Schema
KEY isbn13_status_price (isbn13, status, base_price)
) {$charset_collate};";
// v3: is_free_member's single boolean became a real tier system —
// see Membership\Tier. tier_id NULL means "no tier assigned",
// which every lookup treats the same as the guest tier.
$sql[] = "CREATE TABLE {$prefix}member (
user_id BIGINT UNSIGNED NOT NULL,
is_free_member TINYINT(1) NOT NULL DEFAULT 0,
tier_id BIGINT UNSIGNED NULL,
marketing_consent TINYINT(1) NOT NULL DEFAULT 0,
consent_captured_at DATETIME NULL,
PRIMARY KEY (user_id)
PRIMARY KEY (user_id),
KEY tier_id (tier_id)
) {$charset_collate};";
// v3: admin-configurable membership tiers (Membership\Tier).
// `benefits` is JSON-encoded, not a normalized child table —
// same LONGTEXT-over-JSON-column-type precedent as work.subjects
// above, and the tier count will always be small enough that
// "load all tiers, decode JSON in PHP" is the only query pattern
// that matters. This is what makes adding a tier, or changing an
// existing tier's benefit values, a pure data change with no
// migration.
$sql[] = "CREATE TABLE {$prefix}tier (
tier_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
slug VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
benefits LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tier_id),
UNIQUE KEY slug (slug)
) {$charset_collate};";
// Week 3 (design doc Appendix B / §08 audit data): one row per
// order line, pinning the exact offer charged against so a later
// supplier-submission retry (Order\SupplierOrderJob) targets the
// same offer rather than re-resolving best_offer_for_isbns() a
// third time, and so margin can eventually be tuned from real
// expected-vs-actual contribution instead of guessing.
$sql[] = "CREATE TABLE {$prefix}order_audit (
audit_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
order_item_id BIGINT UNSIGNED NULL,
work_id BIGINT UNSIGNED NOT NULL,
offer_id BIGINT UNSIGNED NOT NULL,
supplier_code VARCHAR(20) NOT NULL,
supplier_cost DECIMAL(10,2) NOT NULL,
supplier_shipping DECIMAL(10,2) NOT NULL,
processor_cost_expected DECIMAL(10,2) NOT NULL,
processor_cost_actual DECIMAL(10,2) NULL,
selling_price DECIMAL(10,2) NOT NULL,
expected_contribution DECIMAL(10,2) NOT NULL,
actual_contribution DECIMAL(10,2) NULL,
recheck_status VARCHAR(20) NOT NULL DEFAULT 'ok',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (audit_id),
KEY order_id (order_id),
KEY offer_id (offer_id)
) {$charset_collate};";
foreach ($sql as $statement) {
@@ -96,12 +148,51 @@ class Schema
}
update_option('bsc_schema_version', BSC_SCHEMA_VERSION);
Tier::seed_defaults();
}
public static function maybe_upgrade(): void
{
if (get_option('bsc_schema_version') !== BSC_SCHEMA_VERSION) {
self::install();
$current = get_option('bsc_schema_version');
if ($current === BSC_SCHEMA_VERSION) {
return;
}
// v2 -> v3: bsc_member's is_free_member flag became tier_id (see
// Membership\Tier). Pre-launch, every environment only holds
// synthetic/test rows, so there's nothing worth backfilling —
// drop and let install() recreate it fresh with the new shape.
// dbDelta() never drops/renames columns on its own, so this is
// the only way an already-deployed site picks up the change.
// Once real customer data exists, schema changes go back to
// real backfill-preserving migrations — this is a pre-launch-
// only shortcut, not a standing pattern.
if ($current !== false && version_compare((string) $current, '3', '<')) {
global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}bsc_member");
}
self::install();
// v3 -> v4: the flat "$10 off $100+" spend_discount benefit
// became a stepped, sitewide promo ($5 per $50 spent, capped at
// $200) — see Membership\MemberDiscount. A genuine product-
// default change, not a hypothetical admin customization worth
// preserving (pre-launch — see project memory on schema
// migrations), so this overwrites guest/free_member's benefits
// directly rather than merging. Post-launch, a change like this
// must target only the specific keys being replaced instead.
if ($current !== false && version_compare((string) $current, '4', '<')) {
$step_discount = ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200];
Tier::update_benefits(Tier::GUEST_SLUG, [
'free_shipping_threshold' => ['threshold' => 25.00],
'step_discount' => $step_discount,
]);
Tier::update_benefits(Tier::FREE_MEMBER_SLUG, [
'free_shipping_threshold' => ['threshold' => 15.00],
'step_discount' => $step_discount,
]);
}
}
}
@@ -2,15 +2,19 @@
namespace Bookstore\Core\Shipping;
use Bookstore\Core\Membership\Tier;
defined('ABSPATH') || exit;
/**
* A real WC_Shipping_Method, not WooCommerce's built-in Free Shipping —
* that only supports one static threshold per instance, and design doc §07
* needs two different ones depending on membership. Thresholds are stored
* as options (bsc_shipping_threshold_guest/_member), not hardcoded, so the
* launch gate's automated test can assert against the same source this
* reads from.
* needs a different one per membership tier. The threshold comes from the
* current customer's `free_shipping_threshold` tier benefit
* (Membership\Tier) rather than a hardcoded option — a human can change it
* per tier via `wp bookstore tier` or the settings screen without a
* deploy, and the launch gate's automated test can assert against the
* same source this reads from.
*/
class BSC_Free_Shipping extends \WC_Shipping_Method
{
@@ -43,19 +47,8 @@ class BSC_Free_Shipping extends \WC_Shipping_Method
public static function threshold_for_current_customer(): float
{
$user_id = get_current_user_id();
$is_member = $user_id && self::is_free_member($user_id);
return $is_member
? (float) get_option('bsc_shipping_threshold_member', 15.00)
: (float) get_option('bsc_shipping_threshold_guest', 25.00);
}
private static function is_free_member(int $user_id): bool
{
global $wpdb;
return (bool) $wpdb->get_var($wpdb->prepare(
"SELECT is_free_member FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
$user_id
));
$config = Tier::benefit_config_for_user($user_id, 'free_shipping_threshold');
return isset($config['threshold']) ? (float) $config['threshold'] : 25.00;
}
public static function ensure_enabled_on_default_zone(): void
@@ -0,0 +1,3 @@
{
"redefinable-internals": ["getenv", "is_readable", "file_get_contents"]
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,40 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
do_action('woocommerce_email_header', $email_heading, $email);
?>
<p>
<?php
printf(
'Order #%s has been routed to the exception queue and needs manual review before it can proceed.',
esc_html($order->get_order_number())
);
?>
</p>
<p><strong>Most recent order note (reason):</strong></p>
<blockquote>
<?php
$notes = wc_get_order_notes(['order_id' => $order->get_id(), 'limit' => 1]);
echo $notes ? wp_kses_post($notes[0]->content) : 'No note found.';
?>
</blockquote>
<?php
do_action('woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email);
if ($additional_content) {
echo wp_kses_post(wpautop(wptexturize($additional_content)));
}
do_action('woocommerce_email_footer', $email);
@@ -0,0 +1,32 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
echo wp_strip_all_tags($email_heading) . "\n\n";
printf(
"Order #%s has been routed to the exception queue and needs manual review before it can proceed.\n\n",
$order->get_order_number()
);
$notes = wc_get_order_notes(['order_id' => $order->get_id(), 'limit' => 1]);
echo "Most recent order note (reason):\n";
echo ($notes ? wp_strip_all_tags($notes[0]->content) : 'No note found.') . "\n\n";
echo "----------------------------------------\n\n";
do_action('woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email);
if ($additional_content) {
echo wp_strip_all_tags(wptexturize($additional_content)) . "\n\n";
}
@@ -0,0 +1,28 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
do_action('woocommerce_email_header', $email_heading, $email);
?>
<p>
<?php printf('Good news — order #%s has shipped!', esc_html($order->get_order_number())); ?>
</p>
<?php
do_action('woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email);
if ($additional_content) {
echo wp_kses_post(wpautop(wptexturize($additional_content)));
}
do_action('woocommerce_email_footer', $email);
@@ -0,0 +1,25 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
echo wp_strip_all_tags($email_heading) . "\n\n";
printf("Good news — order #%s has shipped!\n\n", $order->get_order_number());
echo "----------------------------------------\n\n";
do_action('woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email);
do_action('woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email);
if ($additional_content) {
echo wp_strip_all_tags(wptexturize($additional_content)) . "\n\n";
}
@@ -0,0 +1,31 @@
<?php
namespace Bookstore\Core\Tests;
use Brain\Monkey;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
abstract class TestCase extends PHPUnitTestCase
{
// Without this, Mockery's shouldReceive()/shouldNotReceive()
// expectations are only ever verified at Mockery::close() — PHPUnit
// never sees them as assertions, so a test that mocks a call and
// never asserts anything else passes silently even if the mocked
// call was never (or wrongly) made, just flagged "risky." This
// trait's assertPostConditions() hook feeds Mockery's own verified-
// expectation count into PHPUnit's assertion count instead.
use MockeryPHPUnitIntegration;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
}
@@ -0,0 +1,132 @@
<?php
namespace Bookstore\Core\Tests\Unit\Membership;
use Bookstore\Core\Membership\MemberDiscount;
use Bookstore\Core\Tests\TestCase;
use Brain\Monkey\Functions;
use Mockery;
/**
* The stepped, sitewide promo: $5 off per $50 spent, capped at $200
* spend for a max $20 off (evolved from an earlier flat "$10 off $100+,
* members only" version — design doc §07 extension). "Sitewide" means
* both `guest` and `free_member` carry the same `step_discount` benefit
* by default, so a guest (user_id 0) getting the discount is the thing
* that specifically proves this isn't members-only anymore. The real
* Membership\Tier class runs unmocked — only $wpdb (underneath Tier's
* queries), get_current_user_id(), and a WC_Cart mock are stubbed.
*/
class MemberDiscountTest extends TestCase
{
protected function tearDown(): void
{
global $wpdb;
$wpdb = null;
parent::tearDown();
}
/**
* @dataProvider subtotalCases
*/
public function test_member_gets_the_stepped_discount(float $subtotal, float $expected_discount): void
{
Functions\when('get_current_user_id')->justReturn(7);
$this->stub_wpdb_with_member_lookup(5, [
'free_shipping_threshold' => ['threshold' => 15.00],
'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200],
]);
$cart = Mockery::mock(\WC_Cart::class);
$cart->shouldReceive('get_subtotal')->andReturn((string) $subtotal);
if ($expected_discount > 0) {
$cart->shouldReceive('add_fee')->once()->with('Member discount', -1 * $expected_discount, false);
} else {
$cart->shouldNotReceive('add_fee');
}
MemberDiscount::apply($cart);
}
public static function subtotalCases(): array
{
return [
'nothing in cart' => [0.00, 0.00],
'below the first step' => [30.00, 0.00],
'exactly the first step' => [50.00, 5.00],
'just under the second step' => [99.99, 5.00],
'exactly the second step' => [100.00, 10.00],
'exactly the third step' => [150.00, 15.00],
'just under the cap' => [199.99, 15.00],
'exactly at the cap' => [200.00, 20.00],
'above the cap stays capped' => [250.00, 20.00],
];
}
public function test_guest_gets_the_discount_too_sitewide_by_default(): void
{
Functions\when('get_current_user_id')->justReturn(0);
// guest tier carries the same step_discount benefit as
// free_member by default -- this is what makes it sitewide.
$this->stub_wpdb_anonymous([
'free_shipping_threshold' => ['threshold' => 25.00],
'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200],
]);
$cart = Mockery::mock(\WC_Cart::class);
$cart->shouldReceive('get_subtotal')->andReturn('100.00');
$cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false);
MemberDiscount::apply($cart);
}
public function test_guest_gets_nothing_when_admin_disables_it_on_the_guest_tier(): void
{
Functions\when('get_current_user_id')->justReturn(0);
// Admin unchecked step_discount on the guest row via the
// settings screen -- members-only mode, proven by the absence
// of the benefit key here (not a hardcoded "guest excluded").
$this->stub_wpdb_anonymous([
'free_shipping_threshold' => ['threshold' => 25.00],
]);
$cart = Mockery::mock(\WC_Cart::class);
$cart->shouldReceive('get_subtotal')->andReturn('250.00');
$cart->shouldNotReceive('add_fee');
MemberDiscount::apply($cart);
}
private function stub_wpdb_anonymous(array $benefits): void
{
global $wpdb;
$wpdb = Mockery::mock('wpdb');
$wpdb->prefix = 'wp_';
$wpdb->shouldReceive('prepare')->once()->andReturn('SQL');
$wpdb->shouldReceive('get_var')->never();
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn([
'tier_id' => 1,
'slug' => 'guest',
'name' => 'Guest',
'sort_order' => 0,
'benefits' => json_encode($benefits),
]);
}
private function stub_wpdb_with_member_lookup(int $tier_id, array $benefits): void
{
global $wpdb;
$wpdb = Mockery::mock('wpdb');
$wpdb->prefix = 'wp_';
$wpdb->shouldReceive('prepare')->twice()->andReturn('SQL');
$wpdb->shouldReceive('get_var')->once()->andReturn($tier_id);
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn([
'tier_id' => $tier_id,
'slug' => 'free_member',
'name' => 'Free Member',
'sort_order' => 10,
'benefits' => json_encode($benefits),
]);
}
}
@@ -0,0 +1,119 @@
<?php
namespace Bookstore\Core\Tests\Unit\Order;
use Bookstore\Core\Order\OrderStatuses;
use Bookstore\Core\Order\SupplierOrderJob;
use Bookstore\Core\Tests\TestCase;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Mockery;
class SupplierOrderJobTest extends TestCase
{
public function test_registers_after_stripe_has_saved_its_response(): void
{
Actions\expectAdded('wc_gateway_stripe_process_response')
->with([SupplierOrderJob::class, 'accept_stripe_authorization'], 10, 2)
->once();
Actions\expectAdded('woocommerce_order_status_' . OrderStatuses::AUTHORIZED)->once();
Actions\expectAdded('bsc_submit_supplier_order')->once();
SupplierOrderJob::register();
}
public function test_promotes_a_saved_uncaptured_stripe_authorization(): void
{
Functions\expect('apply_filters')->once()->andReturn([
'pass' => true,
'reason' => 'test pass',
]);
$order = Mockery::mock(\WC_Order::class);
$order->shouldReceive('has_status')->with('on-hold')->once()->andReturn(true);
$order->shouldReceive('get_payment_method')->once()->andReturn('stripe');
$order->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('no');
$order->shouldReceive('add_order_note')->once();
$order->shouldReceive('update_status')
->with(OrderStatuses::AUTHORIZED, Mockery::type('string'))
->once();
SupplierOrderJob::accept_stripe_authorization((object) ['captured' => false], $order);
}
public function test_ignores_an_on_hold_stripe_order_without_persisted_uncaptured_state(): void
{
$order = Mockery::mock(\WC_Order::class);
$order->shouldReceive('has_status')->with('on-hold')->once()->andReturn(true);
$order->shouldReceive('get_payment_method')->once()->andReturn('stripe');
$order->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('');
$order->shouldNotReceive('update_status');
SupplierOrderJob::accept_stripe_authorization((object) [], $order);
}
public function test_ignores_non_authorization_stripe_responses(): void
{
$order = Mockery::mock(\WC_Order::class);
$order->shouldReceive('has_status')->with('on-hold')->once()->andReturn(false);
$order->shouldNotReceive('get_payment_method');
$order->shouldNotReceive('get_meta');
$order->shouldNotReceive('update_status');
SupplierOrderJob::accept_stripe_authorization((object) ['captured' => true], $order);
}
public function test_capture_success_requires_stripes_persisted_confirmation(): void
{
$order = Mockery::mock(\WC_Order::class);
$order->shouldReceive('update_status')->with('processing', Mockery::type('string'))->once();
$order->shouldReceive('get_id')->once()->andReturn(42);
$reloaded = Mockery::mock(\WC_Order::class);
$reloaded->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('yes');
$reloaded->shouldReceive('update_status')
->with(OrderStatuses::SUPPLIER_ORDERED, Mockery::type('string'))
->once();
Functions\expect('wc_get_order')->with(42)->once()->andReturn($reloaded);
$this->invokePrivate('capture_and_complete', $order);
}
public function test_capture_without_stripe_confirmation_routes_to_review(): void
{
$order = Mockery::mock(\WC_Order::class);
$order->shouldReceive('update_status')->with('processing', Mockery::type('string'))->once();
$order->shouldReceive('get_id')->once()->andReturn(43);
$reloaded = Mockery::mock(\WC_Order::class);
$reloaded->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('no');
$reloaded->shouldReceive('update_status')
->with(OrderStatuses::EXCEPTION, Mockery::type('string'))
->once();
Functions\expect('wc_get_order')->with(43)->once()->andReturn($reloaded);
$this->invokePrivate('capture_and_complete', $order);
}
public function test_release_is_recorded_as_requested_not_as_a_confirmed_void(): void
{
$order = Mockery::mock(\WC_Order::class);
$order->shouldReceive('update_meta_data')
->with('_bsc_stripe_release_requested', 'yes')
->once();
$order->shouldReceive('save')->once();
$order->shouldReceive('update_status')->with('cancelled', Mockery::type('string'))->once();
$order->shouldReceive('update_status')
->with(OrderStatuses::EXCEPTION, Mockery::on(
static fn (string $note): bool => str_contains($note, 'verify')
))
->once();
$this->invokePrivate('fail_and_release', $order, 'Unavailable.');
}
private function invokePrivate(string $method, mixed ...$arguments): mixed
{
$reflection = new \ReflectionMethod(SupplierOrderJob::class, $method);
return $reflection->invoke(null, ...$arguments);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Bookstore\Core\Tests\Unit\Pricing;
use Bookstore\Core\Pricing\PricingEngine;
use Bookstore\Core\Tests\TestCase;
/**
* Launch gate: "25% pricing logic ... have automated tests" (design doc
* §5/§8). calculate_retail_price()/round_to_99()/estimate_processing_cost()
* are pure math with no WordPress/WooCommerce dependency, so no mocking
* is needed here.
*/
class PricingEngineTest extends TestCase
{
/**
* @dataProvider retailPriceCases
*/
public function test_calculate_retail_price_applies_25_percent_margin_formula(
float $supplier_base,
float $supplier_shipping,
float $expected
): void {
$actual = PricingEngine::calculate_retail_price($supplier_base, $supplier_shipping);
$this->assertEqualsWithDelta($expected, $actual, 0.001);
}
public static function retailPriceCases(): array
{
return [
'small order' => [10.00, 3.00, 18.99],
'no supplier shipping' => [5.00, 0.00, 7.99],
'near a .99 boundary' => [14.20, 0.05, 20.99],
'zero supplier cost still covers fixed processing' => [0.00, 0.00, 0.99],
'larger order' => [100.00, 10.00, 152.99],
];
}
/**
* @dataProvider roundTo99Cases
*/
public function test_round_to_99_never_rounds_below_the_input(float $price, float $expected): void
{
$this->assertEqualsWithDelta($expected, PricingEngine::round_to_99($price), 0.001);
}
public static function roundTo99Cases(): array
{
return [
// The case the class's own docblock calls out: the old
// ceil($price) - 0.01 formula gave 20.99 here, which is
// below the $20.995 input and would erode the target margin.
'fractional price above the X.99 ending' => [20.995, 21.99],
'exact integer input' => [18.00, 18.99],
'already exactly X.99' => [7.99, 7.99],
'just above X.99' => [8.00, 8.99],
];
}
public function test_estimate_processing_cost(): void
{
$this->assertEqualsWithDelta(0.83, PricingEngine::estimate_processing_cost(20.99), 0.001);
$this->assertEqualsWithDelta(3.00, PricingEngine::estimate_processing_cost(100.00), 0.001);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Bookstore\Core\Tests\Unit\Shipping;
use Bookstore\Core\Shipping\BSC_Free_Shipping;
use Bookstore\Core\Tests\TestCase;
use Brain\Monkey\Functions;
use Mockery;
/**
* Launch gate: "$25/$15 shipping thresholds have automated tests" (design
* doc §5/§8), now sourced from the current customer's tier
* (Membership\Tier) rather than a hardcoded get_option() default. The
* real Tier class runs unmocked here — only $wpdb (the layer underneath
* Tier's own queries) and get_current_user_id() are stubbed, so these
* tests exercise the actual guest-fallback/tier-resolution logic, not
* just BSC_Free_Shipping's arithmetic.
*/
class BSCFreeShippingTest extends TestCase
{
protected function tearDown(): void
{
global $wpdb;
$wpdb = null;
parent::tearDown();
}
public function test_guest_gets_default_25_dollar_threshold(): void
{
Functions\when('get_current_user_id')->justReturn(0);
// user_id 0 short-circuits Tier::tier_for_user()'s ternary --
// $wpdb->get_var() is never called, only find_by_slug('guest').
$this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 25.00]]));
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
}
public function test_logged_in_non_member_gets_guest_threshold(): void
{
Functions\when('get_current_user_id')->justReturn(42);
// No bsc_member row (or a NULL tier_id) -> get_var returns NULL,
// same guest fallback as an anonymous visitor.
$this->stub_wpdb_with_member_lookup(null, $this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 25.00]]));
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
}
public function test_free_member_gets_15_dollar_threshold(): void
{
Functions\when('get_current_user_id')->justReturn(7);
$this->stub_wpdb_with_member_lookup(5, $this->tier_row('free_member', [
'free_shipping_threshold' => ['threshold' => 15.00],
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00],
]));
$this->assertSame(15.00, BSC_Free_Shipping::threshold_for_current_customer());
}
public function test_tier_benefit_value_is_respected_not_a_hardcoded_default(): void
{
Functions\when('get_current_user_id')->justReturn(0);
$this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 29.99]]));
$this->assertSame(29.99, BSC_Free_Shipping::threshold_for_current_customer());
}
public function test_tier_with_no_shipping_benefit_falls_back_to_25(): void
{
Functions\when('get_current_user_id')->justReturn(0);
$this->stub_wpdb_anonymous($this->tier_row('guest', []));
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
}
private function tier_row(string $slug, array $benefits): array
{
return [
'tier_id' => 1,
'slug' => $slug,
'name' => ucfirst($slug),
'sort_order' => 0,
'benefits' => json_encode($benefits),
];
}
/** user_id 0: Tier::tier_for_user() never calls get_var, only find_by_slug('guest') (one prepare+get_row). */
private function stub_wpdb_anonymous(array $resolved_tier_row): void
{
global $wpdb;
$wpdb = Mockery::mock('wpdb');
$wpdb->prefix = 'wp_';
$wpdb->shouldReceive('prepare')->once()->andReturn('SQL');
$wpdb->shouldReceive('get_var')->never();
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn($resolved_tier_row);
}
/** A real user_id: one get_var (member lookup) then one get_row (either find() or the guest fallback), both via prepare(). */
private function stub_wpdb_with_member_lookup(?int $member_tier_id, array $resolved_tier_row): void
{
global $wpdb;
$wpdb = Mockery::mock('wpdb');
$wpdb->prefix = 'wp_';
$wpdb->shouldReceive('prepare')->twice()->andReturn('SQL');
$wpdb->shouldReceive('get_var')->once()->andReturn($member_tier_id);
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn($resolved_tier_row);
}
}
@@ -0,0 +1,66 @@
<?php
/**
* Unit-test bootstrap: pure PHPUnit + Brain Monkey, not a full WordPress
* bootstrap. includes/*.php all guard on `defined('ABSPATH') || exit;`
* at file-load time, and BSC_Free_Shipping extends \WC_Shipping_Method
* (a WooCommerce core class) — both need to exist before Composer's
* autoloader ever pulls those files in, hence defining/stubbing them
* here rather than per test case.
*/
require __DIR__ . '/../vendor/autoload.php';
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
if (!class_exists(\WC_Shipping_Method::class)) {
class WC_Shipping_Method
{
}
}
if (!class_exists(\WC_Cart::class)) {
class WC_Cart
{
}
}
if (!class_exists(\WC_Order::class)) {
class WC_Order
{
}
}
if (!class_exists(\WP_Error::class)) {
class WP_Error
{
private string $message;
public function __construct($code = '', $message = '')
{
$this->message = $message;
}
public function get_error_message(): string
{
return $this->message;
}
}
}
// Membership\Tier calls $wpdb->get_row(..., ARRAY_A) — these are real
// WordPress core constants (wp-includes/wp-db.php) that a full WP
// bootstrap would define; this one deliberately doesn't load WordPress,
// so they need stubbing here with their real values (get_row()/
// get_results() branch on the literal string, not just "is it set").
if (!defined('OBJECT')) {
define('OBJECT', 'OBJECT');
}
if (!defined('ARRAY_A')) {
define('ARRAY_A', 'ARRAY_A');
}
if (!defined('ARRAY_N')) {
define('ARRAY_N', 'ARRAY_N');
}