Week 4 (scoped): BooksRun live price/stock check + daily FTP sync stub

Adds a live per-ISBN BooksRun API check to the checkout-time price
recheck (Order\PriceRecheck) so a booksrun-sourced offer is verified
fresh, not trusted from a potentially day-stale local cache -- fails
closed on any problem (API error, no stock), matching Appendix B's
"stale supplier cost must never be trusted as final." Live values are
written back to the same offer_id row so SupplierOrderJob's later
fulfillment-time read sees them too.

Also wires a real daily Action Scheduler job (scheduling/dedup/batch-write
infrastructure) for BooksRun's FTP inventory feed, but leaves the actual
connect+parse step (Import\BooksRunOfferSync::connect_and_parse_feed())
as a deliberate stub -- no FTP access or sample feed exists yet, and
guessing at a file format would silently write wrong data once real
access arrives. The stub fails loud on the manual CLI trigger, quiet on
the scheduled job (no point alarming on an expected, known state).

Ingram, BooksRun AutoBuy, production email, and tax/legal/monitoring are
explicitly out of scope for this pass -- narrowed by user request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2gmgaDVXcRGCwLfdXQajD
This commit is contained in:
twooeyandClaude Sonnet 5 committed 2026-08-27 21:29:37 -04:00
1 parent 4079bfceac
commit a733ba4bfd
11 files changed
+578 -4

No files matched your search

@@ -70,6 +70,11 @@ add_action('init', function () {
\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();
@@ -6,6 +6,8 @@ defined('ABSPATH') || exit;
class SupplierOffer
{
public const SUPPLIER_BOOKSRUN = 'booksrun';
public static function table(): string
{
global $wpdb;
@@ -71,6 +73,36 @@ class SupplierOffer
return $row ?: null;
}
/**
* Week 4: persists a live per-ISBN recheck (Order\PriceRecheck) back
* into the SAME offer_id row rather than just returning an in-memory
* value — Order\SupplierOrderJob::run() re-reads a pinned offer_id
* from the audit trail at fulfillment time to confirm it's still
* sellable, so the live values have to actually land in the table or
* that later read sees the stale row again and this feature does
* nothing. $wpdb->update() returning 0 (row unchanged, or gone) is
* not an error — only `=== false` is, matching insert()/
* delete_for_isbn() above.
*/
public static function update_live_snapshot(int $offer_id, int $stock_qty, float $base_price, float $supplier_ship, string $status): void
{
global $wpdb;
$result = $wpdb->update(
self::table(),
[
'stock_qty' => $stock_qty,
'base_price' => $base_price,
'supplier_ship' => $supplier_ship,
'status' => $status,
'fetched_at' => current_time('mysql', true),
],
['offer_id' => $offer_id]
);
if ($result === false) {
throw new \RuntimeException('bsc_supplier_offer live-snapshot update failed: ' . $wpdb->last_error . " (offer_id: {$offer_id})");
}
}
public static function delete_for_isbn(string $isbn13): void
{
global $wpdb;
@@ -5,6 +5,8 @@ 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;
@@ -92,6 +94,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.
*
@@ -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
];
}
}
@@ -4,6 +4,7 @@ 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;
@@ -32,12 +33,72 @@ class PriceRecheck
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