From a733ba4bfd581a005a60a0ec628e1a8929c2738d Mon Sep 17 00:00:00 2001 From: Twooey Date: Thu, 27 Aug 2026 21:29:37 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01D2gmgaDVXcRGCwLfdXQajD --- .env.example | 7 + deploy/deploy.sh | 11 +- docker-compose.yml | 21 +- .../plugins/bookstore-core/bookstore-core.php | 5 + .../includes/Catalog/SupplierOffer.php | 32 +++ .../bookstore-core/includes/Cli/Commands.php | 39 ++++ .../BooksRunFtpNotConfiguredException.php | 14 ++ .../includes/Import/BooksRunOfferSync.php | 100 +++++++++ .../includes/Import/BooksRunOfferSyncJob.php | 91 ++++++++ .../includes/Integration/BooksRunAdapter.php | 201 ++++++++++++++++++ .../includes/Order/PriceRecheck.php | 61 ++++++ 11 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 wp-content/plugins/bookstore-core/includes/Import/BooksRunFtpNotConfiguredException.php create mode 100644 wp-content/plugins/bookstore-core/includes/Import/BooksRunOfferSync.php create mode 100644 wp-content/plugins/bookstore-core/includes/Import/BooksRunOfferSyncJob.php create mode 100644 wp-content/plugins/bookstore-core/includes/Integration/BooksRunAdapter.php diff --git a/.env.example b/.env.example index e34f70d..fb1a198 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,13 @@ 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= diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f8f7082..a7f2543 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -75,12 +75,21 @@ printf '%s' "$HELCIM_API_TOKEN_VALUE" > "$SECRETS_DIR/helcim_api_token" if [[ -z "$HELCIM_API_TOKEN_VALUE" ]]; then echo " (HELCIM_API_TOKEN not set in ${ENV_FILE} — Helcim gateway will install but stay unconfigured)" 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" "$SECRETS_DIR/helcim_api_token" +chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token" "$SECRETS_DIR/helcim_api_token" "$SECRETS_DIR/booksrun_api_key" "$SECRETS_DIR/booksrun_ftp_password" echo "==> building and starting ${ENVIRONMENT}" $COMPOSE up -d --build diff --git a/docker-compose.yml b/docker-compose.yml index a294175..0735cbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,9 @@ # 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 and (as -# of Week 3) HELCIM_API_TOKEN now do below. +# it should read `_FILE` variants the same way HARDCOVER_API_TOKEN, +# HELCIM_API_TOKEN (Week 3), and 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,23 @@ 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_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token HELCIM_API_TOKEN_FILE: /run/secrets/helcim_api_token + 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: @@ -99,6 +110,8 @@ services: - ./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}/helcim_api_token:/run/secrets/helcim_api_token: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 @@ -154,6 +167,8 @@ services: - ./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}/helcim_api_token:/run/secrets/helcim_api_token: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: diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index 7e72c96..373ad83 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -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(); diff --git a/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php b/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php index dcce9ba..5cf33dd 100644 --- a/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php +++ b/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php @@ -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; diff --git a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php index 3ce767d..9aeb4a7 100644 --- a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -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. * diff --git a/wp-content/plugins/bookstore-core/includes/Import/BooksRunFtpNotConfiguredException.php b/wp-content/plugins/bookstore-core/includes/Import/BooksRunFtpNotConfiguredException.php new file mode 100644 index 0000000..4e96371 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Import/BooksRunFtpNotConfiguredException.php @@ -0,0 +1,14 @@ +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 + */ + 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.' + ); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Import/BooksRunOfferSyncJob.php b/wp-content/plugins/bookstore-core/includes/Import/BooksRunOfferSyncJob.php new file mode 100644 index 0000000..758c97f --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Import/BooksRunOfferSyncJob.php @@ -0,0 +1,91 @@ +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; + } + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Integration/BooksRunAdapter.php b/wp-content/plugins/bookstore-core/includes/Integration/BooksRunAdapter.php new file mode 100644 index 0000000..697abd2 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Integration/BooksRunAdapter.php @@ -0,0 +1,201 @@ +/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 + ]; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php b/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php index 8ce8b42..6d47b47 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php +++ b/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php @@ -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