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