Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ea299b0e | ||
|
|
21038d68ca | ||
|
|
197938f482 | ||
|
|
773883a0f3 | ||
|
|
13e7362579 | ||
|
|
7023044278 | ||
|
|
8b149a672d | ||
|
|
3143128798 | ||
|
|
5d8533f290 | ||
|
|
a733ba4bfd | ||
|
|
4079bfceac | ||
|
|
73c64d94c1 | ||
|
|
ed70207453 | ||
|
|
b2fd0330a8 |
No files matched your search
+22
-3
@@ -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=
|
||||
@@ -7,6 +7,7 @@
|
||||
/.poll-deploy-*.lock
|
||||
vendor/
|
||||
node_modules/
|
||||
.phpunit.result.cache
|
||||
*.log
|
||||
.DS_Store
|
||||
/.claude/
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+93
-2
@@ -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,69 @@ 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
|
||||
# the top and bottom of the cover. 'uncropped' makes it a proportional
|
||||
# 300x448 instead. wc_get_image_size() caches the computed size in the
|
||||
# object cache, so the option change alone doesn't take effect until flushed.
|
||||
echo "==> uncropped product thumbnails (book covers are portrait, not square)"
|
||||
$WP option update woocommerce_thumbnail_cropping uncropped
|
||||
$WP cache flush
|
||||
|
||||
if [[ "$ENVIRONMENT" != "production" ]]; then
|
||||
echo "==> discouraging search engines (non-production)"
|
||||
$WP option update blog_public 0
|
||||
|
||||
@@ -9,6 +9,19 @@ env_get() {
|
||||
local file="$1" key="$2" line val
|
||||
line="$(grep -m1 -E "^${key}=" "$file" 2>/dev/null || true)"
|
||||
val="${line#*=}"
|
||||
# Strip a trailing \r (a CRLF-saved file — edited on Windows, or via some
|
||||
# SFTP/GUI tools) and any leading/trailing whitespace BEFORE the
|
||||
# quote-detection below. Order matters: a CRLF-terminated quoted value has
|
||||
# its \r land after the closing quote, so the end-with-quote check below
|
||||
# would silently fail to match and the literal quote characters would
|
||||
# leak into the returned value instead of being stripped. Verified
|
||||
# directly: left unstripped, a corrupted DB_NAME value fed into restore.sh's
|
||||
# `DROP DATABASE IF EXISTS \`$DB_NAME_VALUE\`` targets a DIFFERENT
|
||||
# database name than the real one — no error, just a "restore" that
|
||||
# silently doesn't clean the actual target database first.
|
||||
val="${val%$'\r'}"
|
||||
val="${val#"${val%%[![:space:]]*}"}"
|
||||
val="${val%"${val##*[![:space:]]}"}"
|
||||
if [[ "$val" == \"*\" && "$val" == *\" ]]; then
|
||||
val="${val#\"}"; val="${val%\"}"
|
||||
elif [[ "$val" == \'*\' && "$val" == *\' ]]; then
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1928
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 <user> <slug></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,9 +73,54 @@ 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;
|
||||
$wpdb->delete(self::table(), ['isbn13' => $isbn13]);
|
||||
$result = $wpdb->delete(self::table(), ['isbn13' => $isbn13]);
|
||||
if ($result === false) {
|
||||
// The one Catalog method still missing this check, and not a
|
||||
// hypothetical gap: there's no unique constraint on isbn13
|
||||
// alone (schema only has a composite KEY on isbn13+status+
|
||||
// base_price), so SyntheticOfferGenerator's delete-then-insert
|
||||
// pattern relies entirely on the delete actually succeeding.
|
||||
// Verified directly (a forced delete failure via a BEFORE
|
||||
// DELETE trigger): a silent no-op here left the stale row in
|
||||
// place alongside the new insert — two rows for one ISBN, with
|
||||
// best_offer_for_isbns() picking whichever was cheaper by
|
||||
// chance, and generate-offers reporting full success the whole
|
||||
// time. Throwing here routes it into generate_all()'s existing
|
||||
// per-ISBN catch instead of a silent duplicate.
|
||||
throw new \RuntimeException('bsc_supplier_offer delete failed: ' . $wpdb->last_error . ' (isbn13: ' . $isbn13 . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user