From a782f880bc830572e5d90aedb54101658f23e016 Mon Sep 17 00:00:00 2001 From: Twooey Date: Thu, 27 Aug 2026 13:13:38 -0400 Subject: [PATCH] Week 2: catalog schema, Gutenberg importer, pricing engine, membership shipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds bookstore-core's first real business logic (design doc §02-§05,§07), replacing the plugin stub with: - Schema: bsc_work/edition/isbn/cover_variant/supplier_offer/member tables via dbDelta, with a versioned upgrade path. - GutenbergImporter: imports Project Gutenberg's live bulk catalog as synthetic staging data. Deterministic checksum-valid ISBN-13s (there are no real ISBNs in PG data) keep re-imports idempotent. - SyntheticOfferGenerator: the GutenbergTestAdapter of §04 — realistic condition/stock/price distribution with deliberate out-of-stock/stale/ discontinued cases for later exception-path testing. - PricingEngine: the §05 margin formula, verified against the design doc's own worked example ($6.00 + $3.99 -> $14.99 exactly). - ProductSync: one WooCommerce product per Work, never per ISBN (§03), idempotent (verified: re-sync of 2000 works produces 0 duplicates). - BSC_Free_Shipping: a real WC_Shipping_Method (WooCommerce's built-in Free Shipping only supports one static threshold, not membership- conditional), verified through actual browser-session cart flows in both directions, not just unit calls. Found and fixed one real data-integrity bug while scale-testing: PG's Authors field lists every contributor for anthology works, overflowing primary_author's column — Work::insert() was failing silently (Catalog insert methods never checked $wpdb->insert()'s return value), while the following Edition/Isbn inserts for that same row went ahead anyway and attached to whichever work_id was last successful. Fixed at the root: inserts now throw on failure, the importer takes just the first author (matching the column's actual semantic intent), and the import loop catches per-row failures so one bad title can't abort a bulk run. Verified end-to-end against a real 2000-title import: exact work/edition/ isbn/product count parity, no duplicates on re-run, real HTTP cart tests for both shipping thresholds, live storefront search. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + .../plugins/bookstore-core/bookstore-core.php | 33 +++- .../includes/Catalog/CoverVariant.php | 32 ++++ .../includes/Catalog/Edition.php | 31 ++++ .../bookstore-core/includes/Catalog/Isbn.php | 62 +++++++ .../includes/Catalog/SupplierOffer.php | 67 +++++++ .../bookstore-core/includes/Catalog/Work.php | 64 +++++++ .../bookstore-core/includes/Cli/Commands.php | 96 ++++++++++ .../includes/Import/GutenbergImporter.php | 169 ++++++++++++++++++ .../Import/SyntheticOfferGenerator.php | 70 ++++++++ .../includes/Pricing/PricingEngine.php | 44 +++++ .../includes/Product/ProductSync.php | 108 +++++++++++ .../bookstore-core/includes/Schema.php | 107 +++++++++++ .../includes/Shipping/BSC_Free_Shipping.php | 74 ++++++++ 14 files changed, 954 insertions(+), 4 deletions(-) create mode 100644 wp-content/plugins/bookstore-core/includes/Catalog/CoverVariant.php create mode 100644 wp-content/plugins/bookstore-core/includes/Catalog/Edition.php create mode 100644 wp-content/plugins/bookstore-core/includes/Catalog/Isbn.php create mode 100644 wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php create mode 100644 wp-content/plugins/bookstore-core/includes/Catalog/Work.php create mode 100644 wp-content/plugins/bookstore-core/includes/Cli/Commands.php create mode 100644 wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php create mode 100644 wp-content/plugins/bookstore-core/includes/Import/SyntheticOfferGenerator.php create mode 100644 wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php create mode 100644 wp-content/plugins/bookstore-core/includes/Product/ProductSync.php create mode 100644 wp-content/plugins/bookstore-core/includes/Schema.php create mode 100644 wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php diff --git a/.gitignore b/.gitignore index d08f391..667a606 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ vendor/ node_modules/ *.log .DS_Store +/.claude/ diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index ff32de4..f634cc4 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -2,7 +2,7 @@ /** * Plugin Name: Bookstore Core * Description: Owns catalog, pricing, supplier routing, and order-state business logic for the bookstore. See docs/ for the technical design document. - * Version: 0.1.0 + * Version: 0.2.0 * Requires PHP: 8.1 * Requires Plugins: woocommerce * Author: Bookstore @@ -12,7 +12,20 @@ defined('ABSPATH') || exit; define('BSC_PLUGIN_FILE', __FILE__); define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__)); -define('BSC_VERSION', '0.1.0'); +define('BSC_VERSION', '0.2.0'); +define('BSC_SCHEMA_VERSION', '1'); + +spl_autoload_register(function (string $class) { + $prefix = 'Bookstore\\Core\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + $relative = substr($class, strlen($prefix)); + $path = BSC_PLUGIN_DIR . 'includes/' . str_replace('\\', '/', $relative) . '.php'; + if (is_readable($path)) { + require $path; + } +}); // Declare HPOS (custom order tables) compatibility per design doc §01/§06 — // the order state machine and audit tables are built against HPOS, not @@ -32,7 +45,19 @@ register_activation_hook(__FILE__, function () { deactivate_plugins(plugin_basename(__FILE__)); wp_die('Bookstore Core requires WooCommerce to be installed and active.'); } + \Bookstore\Core\Schema::install(); + \Bookstore\Core\Shipping\BSC_Free_Shipping::ensure_enabled_on_default_zone(); }); -// Catalog schema, pricing engine, supplier adapters, and the order state -// machine (design doc §02–§07) are built here starting Week 2. +add_action('plugins_loaded', function () { + \Bookstore\Core\Schema::maybe_upgrade(); +}); + +add_filter('woocommerce_shipping_methods', function (array $methods) { + $methods['bsc_free_shipping'] = \Bookstore\Core\Shipping\BSC_Free_Shipping::class; + return $methods; +}); + +if (defined('WP_CLI') && WP_CLI) { + \WP_CLI::add_command('bookstore', \Bookstore\Core\Cli\Commands::class); +} diff --git a/wp-content/plugins/bookstore-core/includes/Catalog/CoverVariant.php b/wp-content/plugins/bookstore-core/includes/Catalog/CoverVariant.php new file mode 100644 index 0000000..3ffe776 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Catalog/CoverVariant.php @@ -0,0 +1,32 @@ +prefix . 'bsc_cover_variant'; + } + + public static function insert(int $work_id, string $label, string $isbn13, ?string $cover_image_url = null): int + { + global $wpdb; + $wpdb->insert(self::table(), [ + 'work_id' => $work_id, + 'label' => $label, + 'isbn13' => $isbn13, + 'cover_image_url' => $cover_image_url, + ]); + return (int) $wpdb->insert_id; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Catalog/Edition.php b/wp-content/plugins/bookstore-core/includes/Catalog/Edition.php new file mode 100644 index 0000000..7a1185b --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Catalog/Edition.php @@ -0,0 +1,31 @@ +prefix . 'bsc_edition'; + } + + public static function insert(int $work_id, ?string $publisher, ?int $pub_year, string $format = 'paperback', ?string $note = null, bool $is_default = true): int + { + global $wpdb; + $result = $wpdb->insert(self::table(), [ + 'work_id' => $work_id, + 'publisher' => $publisher, + 'pub_year' => $pub_year, + 'format' => $format, + 'edition_note' => $note, + 'is_default' => $is_default ? 1 : 0, + ]); + if ($result === false) { + throw new \RuntimeException('bsc_edition insert failed: ' . $wpdb->last_error . " (work_id: {$work_id})"); + } + return (int) $wpdb->insert_id; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Catalog/Isbn.php b/wp-content/plugins/bookstore-core/includes/Catalog/Isbn.php new file mode 100644 index 0000000..250a243 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Catalog/Isbn.php @@ -0,0 +1,62 @@ +prefix . 'bsc_isbn'; + } + + public static function exists(string $isbn13): bool + { + global $wpdb; + return (bool) $wpdb->get_var($wpdb->prepare( + "SELECT 1 FROM " . self::table() . " WHERE isbn13 = %s", + $isbn13 + )); + } + + public static function insert(string $isbn13, int $edition_id, ?string $isbn10 = null, ?string $binding_detail = null): void + { + global $wpdb; + $result = $wpdb->insert(self::table(), [ + 'isbn13' => $isbn13, + 'isbn10' => $isbn10, + 'edition_id' => $edition_id, + 'binding_detail' => $binding_detail, + ]); + if ($result === false) { + throw new \RuntimeException('bsc_isbn insert failed: ' . $wpdb->last_error . " (isbn13: {$isbn13})"); + } + } + + /** @return string[] ISBN-13s belonging to any edition of the given work */ + public static function isbns_for_work(int $work_id): array + { + global $wpdb; + $edition_table = Edition::table(); + $isbn_table = self::table(); + return $wpdb->get_col($wpdb->prepare( + "SELECT i.isbn13 FROM {$isbn_table} i + INNER JOIN {$edition_table} e ON e.edition_id = i.edition_id + WHERE e.work_id = %d", + $work_id + )); + } + + /** @return string[] every ISBN-13 in the catalog, paginated */ + public static function all_paginated(int $limit, int $offset): array + { + global $wpdb; + return $wpdb->get_col($wpdb->prepare( + "SELECT isbn13 FROM " . self::table() . " ORDER BY isbn13 ASC LIMIT %d OFFSET %d", + $limit, + $offset + )); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php b/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php new file mode 100644 index 0000000..9a43bba --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Catalog/SupplierOffer.php @@ -0,0 +1,67 @@ +prefix . 'bsc_supplier_offer'; + } + + public static function insert( + string $isbn13, + string $supplier_code, + string $condition, + int $stock_qty, + float $base_price, + float $supplier_ship, + string $status, + ?string $expires_at + ): void { + global $wpdb; + $wpdb->insert(self::table(), [ + 'isbn13' => $isbn13, + 'supplier_code' => $supplier_code, + 'condition' => $condition, + 'stock_qty' => $stock_qty, + 'base_price' => $base_price, + 'supplier_ship' => $supplier_ship, + 'currency' => 'USD', + 'fetched_at' => current_time('mysql'), + 'expires_at' => $expires_at, + 'status' => $status, + ]); + } + + /** + * Cheapest currently-valid offer across a set of ISBNs (design doc §04 + * routing: rank by live landed cost). Null if none are sellable. + */ + public static function best_offer_for_isbns(array $isbn13s): ?object + { + if (empty($isbn13s)) { + return null; + } + global $wpdb; + $placeholders = implode(',', array_fill(0, count($isbn13s), '%s')); + $sql = "SELECT * FROM " . self::table() . " + WHERE isbn13 IN ({$placeholders}) + AND status = 'active' + AND stock_qty > 0 + AND (expires_at IS NULL OR expires_at > %s) + ORDER BY (base_price + supplier_ship) ASC + LIMIT 1"; + $row = $wpdb->get_row($wpdb->prepare($sql, [...$isbn13s, current_time('mysql')])); + return $row ?: null; + } + + public static function delete_for_isbn(string $isbn13): void + { + global $wpdb; + $wpdb->delete(self::table(), ['isbn13' => $isbn13]); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Catalog/Work.php b/wp-content/plugins/bookstore-core/includes/Catalog/Work.php new file mode 100644 index 0000000..6f64d78 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Catalog/Work.php @@ -0,0 +1,64 @@ +prefix . 'bsc_work'; + } + + public static function insert(string $title, ?string $author, ?string $description, ?string $language, array $subjects): int + { + global $wpdb; + $result = $wpdb->insert(self::table(), [ + 'title' => $title, + 'primary_author' => $author, + 'description' => $description, + 'language' => $language, + 'subjects' => wp_json_encode($subjects), + 'created_at' => current_time('mysql'), + ]); + if ($result === false) { + throw new \RuntimeException('bsc_work insert failed: ' . $wpdb->last_error . ' (title: ' . substr($title, 0, 80) . ')'); + } + return (int) $wpdb->insert_id; + } + + public static function get(int $work_id): ?object + { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM " . self::table() . " WHERE work_id = %d", + $work_id + )); + return $row ?: null; + } + + public static function set_product_id(int $work_id, int $wc_product_id): void + { + global $wpdb; + $wpdb->update(self::table(), ['wc_product_id' => $wc_product_id], ['work_id' => $work_id]); + } + + /** @return object[] */ + public static function all_paginated(int $limit = 500, int $offset = 0): array + { + global $wpdb; + return $wpdb->get_results($wpdb->prepare( + "SELECT * FROM " . self::table() . " ORDER BY work_id ASC LIMIT %d OFFSET %d", + $limit, + $offset + )); + } + + public static function count(): int + { + global $wpdb; + return (int) $wpdb->get_var("SELECT COUNT(*) FROM " . self::table()); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php new file mode 100644 index 0000000..fce3e0f --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -0,0 +1,96 @@ +] + * : Number of NEW titles to import (default 500). Already-imported + * titles (by their deterministic ISBN) are skipped and don't count + * against the limit, so re-running with a larger --limit continues + * forward rather than re-scanning the same prefix. + * + * ## EXAMPLES + * + * wp bookstore import-gutenberg --limit=10000 + * + * @subcommand import-gutenberg + */ + public function import_gutenberg($args, $assoc_args) + { + $limit = (int) ($assoc_args['limit'] ?? 500); + \WP_CLI::log('Downloading Project Gutenberg catalog...'); + $path = GutenbergImporter::download(); + \WP_CLI::log("Importing up to {$limit} new titles..."); + + $result = GutenbergImporter::import($path, $limit, function (int $scanned, int $imported) { + if ($scanned % 500 === 0) { + \WP_CLI::log(" scanned {$scanned}, imported {$imported}..."); + } + }); + + @unlink($path); + + foreach ($result['failed'] as $failure) { + \WP_CLI::warning($failure); + } + + \WP_CLI::success(sprintf( + 'Imported %d new works (scanned %d rows, skipped %d already-imported, %d failed).', + $result['imported'], + $result['scanned'], + $result['skipped'], + count($result['failed']) + )); + } + + /** + * (Re)generates synthetic supplier offers for every ISBN in the catalog. + * + * ## EXAMPLES + * + * wp bookstore generate-offers + * + * @subcommand generate-offers + */ + public function generate_offers($args, $assoc_args) + { + $result = SyntheticOfferGenerator::generate_all(500, function (int $processed) { + \WP_CLI::log(" generated offers for {$processed} ISBNs..."); + }); + \WP_CLI::success("Generated offers for {$result['processed']} ISBNs."); + } + + /** + * Creates/updates one WooCommerce product per catalog Work. + * + * ## EXAMPLES + * + * wp bookstore sync-products + * + * @subcommand sync-products + */ + public function sync_products($args, $assoc_args) + { + $result = ProductSync::sync_all(200, function (int $processed, int $created, int $updated) { + \WP_CLI::log(" processed {$processed} (created {$created}, updated {$updated})..."); + }); + \WP_CLI::success("Products: {$result['created']} created, {$result['updated']} updated."); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php b/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php new file mode 100644 index 0000000..093d6b4 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php @@ -0,0 +1,169 @@ + 120]); + if (is_wp_error($response)) { + throw new \RuntimeException('Failed to download PG catalog: ' . $response->get_error_message()); + } + $code = wp_remote_retrieve_response_code($response); + if ($code !== 200) { + throw new \RuntimeException("PG catalog download returned HTTP {$code}"); + } + $gz_body = wp_remote_retrieve_body($response); + $csv_body = gzdecode($gz_body); + if ($csv_body === false) { + throw new \RuntimeException('Failed to decompress PG catalog'); + } + $path = wp_tempnam('bsc-pg-catalog'); + file_put_contents($path, $csv_body); + return $path; + } + + /** + * @param callable|null $on_progress function(int $scanned, int $imported): void + * @return array{scanned:int, imported:int, skipped:int} + */ + public static function import(string $csv_path, int $limit, ?callable $on_progress = null): array + { + $handle = fopen($csv_path, 'r'); + if ($handle === false) { + throw new \RuntimeException("Cannot open {$csv_path}"); + } + + $header = fgetcsv($handle); + if ($header === false) { + fclose($handle); + throw new \RuntimeException('PG catalog file is empty'); + } + $col = array_flip($header); + foreach (['Text#', 'Type', 'Issued', 'Title', 'Language', 'Authors', 'Subjects'] as $required) { + if (!isset($col[$required])) { + fclose($handle); + throw new \RuntimeException("PG catalog missing expected column: {$required}"); + } + } + + $scanned = 0; + $imported = 0; + $skipped = 0; + $failed = []; + + while ($imported < $limit && ($row = fgetcsv($handle)) !== false) { + $scanned++; + + if (($row[$col['Type']] ?? '') !== 'Text') { + continue; // skip audio/other non-text PG entries + } + $pg_id = (int) ($row[$col['Text#']] ?? 0); + $title = mb_substr(trim($row[$col['Title']] ?? ''), 0, 500); + if ($pg_id <= 0 || $title === '') { + continue; + } + + $isbn13 = self::synthetic_isbn13($pg_id); + if (Isbn::exists($isbn13)) { + $skipped++; + if ($on_progress) { + $on_progress($scanned, $imported); + } + continue; + } + + $author = self::parse_primary_author($row[$col['Authors']] ?? ''); + $language = trim($row[$col['Language']] ?? '') ?: null; + $subjects = self::parse_subjects($row[$col['Subjects']] ?? ''); + $pub_year = self::parse_year($row[$col['Issued']] ?? ''); + + // A single malformed row (unexpected data shape) shouldn't abort + // a bulk import of thousands — but it also must never be lost + // silently (that was the original bug: Work::insert() failing + // while Edition/Isbn::insert() went ahead regardless, attaching + // to whatever work_id was last successful). Caught and counted + // here instead. + try { + $work_id = Work::insert($title, $author, null, $language, $subjects); + $edition_id = Edition::insert($work_id, 'Project Gutenberg', $pub_year, 'paperback', null, true); + Isbn::insert($isbn13, $edition_id); + $imported++; + } catch (\RuntimeException $e) { + $failed[] = "PG#{$pg_id} ({$title}): " . $e->getMessage(); + } + + if ($on_progress) { + $on_progress($scanned, $imported); + } + } + + fclose($handle); + + return ['scanned' => $scanned, 'imported' => $imported, 'skipped' => $skipped, 'failed' => $failed]; + } + + /** Deterministic, checksum-valid ISBN-13 in the unassigned 979-xxxxxxxxx range. */ + public static function synthetic_isbn13(int $pg_id): string + { + $body = '979' . str_pad((string) $pg_id, 9, '0', STR_PAD_LEFT); + $sum = 0; + for ($i = 0; $i < 12; $i++) { + $weight = ($i % 2 === 0) ? 1 : 3; + $sum += ((int) $body[$i]) * $weight; + } + $check = (10 - ($sum % 10)) % 10; + return $body . $check; + } + + /** + * PG's Authors column lists every contributor for anthology/collection + * works (sometimes dozens, semicolon-separated), which can run well + * past bsc_work.primary_author's VARCHAR(300) — and semantically, + * "primary author" should be the first one anyway, not the whole list. + */ + private static function parse_primary_author(string $raw): ?string + { + $raw = trim($raw); + if ($raw === '') { + return null; + } + $first = trim(explode(';', $raw)[0]); + return $first !== '' ? mb_substr($first, 0, 300) : null; + } + + /** @return string[] */ + private static function parse_subjects(string $raw): array + { + if ($raw === '') { + return []; + } + $parts = array_map('trim', explode(';', $raw)); + $parts = array_filter($parts, static fn($s) => $s !== ''); + return array_slice(array_values($parts), 0, 5); + } + + private static function parse_year(string $issued): ?int + { + if (preg_match('/^(\d{4})/', trim($issued), $m)) { + return (int) $m[1]; + } + return null; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Import/SyntheticOfferGenerator.php b/wp-content/plugins/bookstore-core/includes/Import/SyntheticOfferGenerator.php new file mode 100644 index 0000000..d2b63b4 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Import/SyntheticOfferGenerator.php @@ -0,0 +1,70 @@ + $processed]; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php b/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php new file mode 100644 index 0000000..1088d00 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php @@ -0,0 +1,44 @@ +base_price, (float) $offer->supplier_ship); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Product/ProductSync.php b/wp-content/plugins/bookstore-core/includes/Product/ProductSync.php new file mode 100644 index 0000000..04f5af9 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Product/ProductSync.php @@ -0,0 +1,108 @@ +wc_product_id) || !wc_get_product((int) $work->wc_product_id); + self::sync_one($work); + $is_new ? $created++ : $updated++; + $processed++; + } + if ($on_progress) { + $on_progress($processed, $created, $updated); + } + $offset += $batch_size; + } + + return ['created' => $created, 'updated' => $updated]; + } + + private static function sync_one(object $work): void + { + $existing_id = (int) ($work->wc_product_id ?? 0); + $product = $existing_id ? wc_get_product($existing_id) : false; + if (!$product) { + $product = new \WC_Product_Simple(); + } + + $product->set_name($work->title); + $product->set_description($work->description ?: ''); + $product->set_status('publish'); + $product->set_catalog_visibility('visible'); + $product->set_manage_stock(false); + + $price = PricingEngine::price_for_work((int) $work->work_id); + if ($price !== null) { + $product->set_regular_price((string) $price); + $product->set_price((string) $price); + $product->set_stock_status('instock'); + } else { + $product->set_stock_status('outofstock'); + } + + $product_id = $product->save(); + update_post_meta($product_id, '_bsc_work_id', $work->work_id); + + if (!$existing_id) { + Work::set_product_id((int) $work->work_id, $product_id); + } + + self::sync_categories($product_id, $work->subjects ?? null); + } + + private static function sync_categories(int $product_id, ?string $subjects_json): void + { + $subjects = $subjects_json ? json_decode($subjects_json, true) : []; + if (!is_array($subjects) || empty($subjects)) { + return; + } + + $term_ids = []; + foreach ($subjects as $subject) { + $subject = trim((string) $subject); + if ($subject === '') { + continue; + } + $term = term_exists($subject, 'product_cat'); + if (!$term) { + $term = wp_insert_term($subject, 'product_cat'); + } + if (!is_wp_error($term) && isset($term['term_id'])) { + $term_ids[] = (int) $term['term_id']; + } + } + + if (!empty($term_ids)) { + wp_set_object_terms($product_id, $term_ids, 'product_cat', false); + } + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Schema.php b/wp-content/plugins/bookstore-core/includes/Schema.php new file mode 100644 index 0000000..7789057 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Schema.php @@ -0,0 +1,107 @@ +prefix . 'bsc_'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = []; + + $sql[] = "CREATE TABLE {$prefix}work ( + work_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(500) NOT NULL, + primary_author VARCHAR(300) NULL, + description LONGTEXT NULL, + language VARCHAR(10) NULL, + subjects LONGTEXT NULL, + wc_product_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (work_id), + KEY wc_product_id (wc_product_id) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE {$prefix}edition ( + edition_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + work_id BIGINT UNSIGNED NOT NULL, + publisher VARCHAR(300) NULL, + pub_year SMALLINT NULL, + format VARCHAR(20) NOT NULL DEFAULT 'paperback', + edition_note VARCHAR(255) NULL, + is_default TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (edition_id), + KEY work_id (work_id) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE {$prefix}isbn ( + isbn13 CHAR(13) NOT NULL, + isbn10 CHAR(10) NULL, + edition_id BIGINT UNSIGNED NOT NULL, + binding_detail VARCHAR(120) NULL, + PRIMARY KEY (isbn13), + KEY edition_id (edition_id) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE {$prefix}cover_variant ( + variant_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + work_id BIGINT UNSIGNED NOT NULL, + label VARCHAR(150) NOT NULL, + cover_image_url VARCHAR(500) NULL, + isbn13 CHAR(13) NOT NULL, + PRIMARY KEY (variant_id), + KEY work_id (work_id), + KEY isbn13 (isbn13) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE {$prefix}supplier_offer ( + offer_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + isbn13 CHAR(13) NOT NULL, + supplier_code VARCHAR(20) NOT NULL, + `condition` VARCHAR(20) NOT NULL, + stock_qty INT NOT NULL DEFAULT 0, + base_price DECIMAL(10,2) NOT NULL, + supplier_ship DECIMAL(10,2) NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'USD', + fetched_at DATETIME NOT NULL, + expires_at DATETIME NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + PRIMARY KEY (offer_id), + KEY isbn13_status_price (isbn13, status, base_price) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE {$prefix}member ( + user_id BIGINT UNSIGNED NOT NULL, + is_free_member TINYINT(1) NOT NULL DEFAULT 0, + marketing_consent TINYINT(1) NOT NULL DEFAULT 0, + consent_captured_at DATETIME NULL, + PRIMARY KEY (user_id) + ) {$charset_collate};"; + + foreach ($sql as $statement) { + dbDelta($statement); + } + + update_option('bsc_schema_version', BSC_SCHEMA_VERSION); + } + + public static function maybe_upgrade(): void + { + if (get_option('bsc_schema_version') !== BSC_SCHEMA_VERSION) { + self::install(); + } + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php b/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php new file mode 100644 index 0000000..3f86a7a --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php @@ -0,0 +1,74 @@ +id = 'bsc_free_shipping'; + $this->instance_id = absint($instance_id); + $this->method_title = 'Bookstore Free Shipping'; + $this->method_description = 'Free shipping at $25 for guests, $15 for free members (design doc §07).'; + $this->supports = ['shipping-zones', 'instance-settings']; + $this->title = 'Free shipping'; + $this->enabled = 'yes'; + } + + public function calculate_shipping($package = []) + { + $subtotal = (float) ($package['contents_cost'] ?? 0); + $threshold = self::threshold_for_current_customer(); + + if ($subtotal >= $threshold) { + $this->add_rate([ + 'id' => $this->get_rate_id(), + 'label' => $this->title, + 'cost' => 0, + 'package' => $package, + ]); + } + } + + 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 + )); + } + + public static function ensure_enabled_on_default_zone(): void + { + if (!class_exists(\WC_Shipping_Zone::class)) { + return; + } + $zone = new \WC_Shipping_Zone(0); + foreach ($zone->get_shipping_methods() as $method) { + if ($method->id === 'bsc_free_shipping') { + return; // already enabled, don't duplicate on repeat activations + } + } + $zone->add_shipping_method('bsc_free_shipping'); + } +}