Week 2: catalog schema, Gutenberg importer, pricing engine, membership shipping

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:13:38 -04:00
co-authored by Claude Sonnet 5
parent ea144d0298
commit a782f880bc
14 changed files with 954 additions and 4 deletions
+1
View File
@@ -8,3 +8,4 @@ vendor/
node_modules/ node_modules/
*.log *.log
.DS_Store .DS_Store
/.claude/
@@ -2,7 +2,7 @@
/** /**
* Plugin Name: Bookstore Core * 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. * 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 PHP: 8.1
* Requires Plugins: woocommerce * Requires Plugins: woocommerce
* Author: Bookstore * Author: Bookstore
@@ -12,7 +12,20 @@ defined('ABSPATH') || exit;
define('BSC_PLUGIN_FILE', __FILE__); define('BSC_PLUGIN_FILE', __FILE__);
define('BSC_PLUGIN_DIR', plugin_dir_path(__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 — // Declare HPOS (custom order tables) compatibility per design doc §01/§06 —
// the order state machine and audit tables are built against HPOS, not // 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__)); deactivate_plugins(plugin_basename(__FILE__));
wp_die('Bookstore Core requires WooCommerce to be installed and active.'); 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 add_action('plugins_loaded', function () {
// machine (design doc §02–§07) are built here starting Week 2. \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);
}
@@ -0,0 +1,32 @@
<?php
namespace Bookstore\Core\Catalog;
defined('ABSPATH') || exit;
/**
* Unused by the Gutenberg importer (design doc §02: only populated "when
* the supplier metadata makes the choice defensible" — PG's CSV has no
* cover-choice data). Table exists now so real supplier data can use it
* later without a schema migration.
*/
class CoverVariant
{
public static function table(): string
{
global $wpdb;
return $wpdb->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;
}
}
@@ -0,0 +1,31 @@
<?php
namespace Bookstore\Core\Catalog;
defined('ABSPATH') || exit;
class Edition
{
public static function table(): string
{
global $wpdb;
return $wpdb->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;
}
}
@@ -0,0 +1,62 @@
<?php
namespace Bookstore\Core\Catalog;
defined('ABSPATH') || exit;
class Isbn
{
public static function table(): string
{
global $wpdb;
return $wpdb->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
));
}
}
@@ -0,0 +1,67 @@
<?php
namespace Bookstore\Core\Catalog;
defined('ABSPATH') || exit;
class SupplierOffer
{
public static function table(): string
{
global $wpdb;
return $wpdb->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]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Bookstore\Core\Catalog;
defined('ABSPATH') || exit;
class Work
{
public static function table(): string
{
global $wpdb;
return $wpdb->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());
}
}
@@ -0,0 +1,96 @@
<?php
namespace Bookstore\Core\Cli;
use Bookstore\Core\Import\GutenbergImporter;
use Bookstore\Core\Import\SyntheticOfferGenerator;
use Bookstore\Core\Product\ProductSync;
defined('ABSPATH') || exit;
/**
* wp-cli commands are the operational surface for the catalog pipeline —
* this is bulk/staging tooling, not something an admin UI page is worth
* building for.
*/
class Commands
{
/**
* Imports Project Gutenberg's catalog as synthetic staging data.
*
* ## OPTIONS
*
* [--limit=<number>]
* : 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.");
}
}
@@ -0,0 +1,169 @@
<?php
namespace Bookstore\Core\Import;
use Bookstore\Core\Catalog\Edition;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\Work;
defined('ABSPATH') || exit;
/**
* Imports Project Gutenberg's weekly bulk catalog as synthetic staging
* data (design doc §02/§04). PG has no ISBNs — a checksum-valid ISBN-13 is
* deterministically derived from each PG Text# so re-imports are
* idempotent and stable across runs.
*/
class GutenbergImporter
{
private const CATALOG_URL = 'https://www.gutenberg.org/cache/epub/feeds/pg_catalog.csv.gz';
/** Downloads and decompresses the catalog, returning a local temp file path. */
public static function download(): string
{
$response = wp_remote_get(self::CATALOG_URL, ['timeout' => 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;
}
}
@@ -0,0 +1,70 @@
<?php
namespace Bookstore\Core\Import;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
defined('ABSPATH') || exit;
/**
* The GutenbergTestAdapter of design doc §04: generates one synthetic
* offer per ISBN under supplier_code=gutenberg_test (the only adapter
* ACTIVE_SUPPLIER_ADAPTERS actually names for staging), with deliberate
* out-of-stock/stale/discontinued cases mixed in so the exception paths
* in §06 have something real to exercise later.
*/
class SyntheticOfferGenerator
{
private const CONDITIONS = ['new', 'like_new', 'very_good', 'good', 'acceptable'];
public static function generate_for_isbn(string $isbn13): void
{
SupplierOffer::delete_for_isbn($isbn13);
$roll = mt_rand(1, 100);
$status = $roll <= 3 ? 'discontinued' : ($roll <= 8 ? 'stale' : 'active');
$stock_qty = mt_rand(1, 100) <= 10 ? 0 : mt_rand(1, 15);
$condition = self::CONDITIONS[array_rand(self::CONDITIONS)];
$base_price = round(mt_rand(200, 1800) / 100, 2);
$supplier_ship = round(mt_rand(0, 499) / 100, 2);
$expires_at = gmdate('Y-m-d H:i:s', time() + mt_rand(1, 14) * DAY_IN_SECONDS);
SupplierOffer::insert(
$isbn13,
'gutenberg_test',
$condition,
$stock_qty,
$base_price,
$supplier_ship,
$status,
$expires_at
);
}
/**
* @param callable|null $on_progress function(int $processed): void
* @return array{processed:int}
*/
public static function generate_all(int $batch_size = 500, ?callable $on_progress = null): array
{
$processed = 0;
$offset = 0;
while (true) {
$isbns = Isbn::all_paginated($batch_size, $offset);
if (empty($isbns)) {
break;
}
foreach ($isbns as $isbn13) {
self::generate_for_isbn($isbn13);
$processed++;
}
if ($on_progress) {
$on_progress($processed);
}
$offset += $batch_size;
}
return ['processed' => $processed];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Bookstore\Core\Pricing;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
defined('ABSPATH') || exit;
/**
* Design doc §05: Retail Price = (Supplier Base + Supplier Shipping +
* Fixed Processing) / (1 - Processing Rate - Target Margin). Processing
* Rate is a placeholder blended estimate — tune from real Helcim
* settlement data once it exists, not from guessing.
*/
class PricingEngine
{
private const FIXED_PROCESSING = 0.25;
private const PROCESSING_RATE = 0.0275;
private const TARGET_MARGIN = 0.25;
public static function calculate_retail_price(float $supplier_base, float $supplier_shipping): float
{
$raw = ($supplier_base + $supplier_shipping + self::FIXED_PROCESSING)
/ (1 - self::PROCESSING_RATE - self::TARGET_MARGIN);
return self::round_to_99($raw);
}
public static function round_to_99(float $price): float
{
return ceil($price) - 0.01;
}
/** Null if the work has no currently sellable offer (design doc §03: fulfillment resolved at render time). */
public static function price_for_work(int $work_id): ?float
{
$isbns = Isbn::isbns_for_work($work_id);
$offer = SupplierOffer::best_offer_for_isbns($isbns);
if (!$offer) {
return null;
}
return self::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship);
}
}
@@ -0,0 +1,108 @@
<?php
namespace Bookstore\Core\Product;
use Bookstore\Core\Catalog\Work;
use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit;
/**
* Design doc §03: exactly one WooCommerce product per Work — never per
* ISBN or offer. Price/stock are read fresh from PricingEngine/offers on
* every sync, not cached on the product beyond what WooCommerce itself
* needs to render a price.
*/
class ProductSync
{
/**
* @param callable|null $on_progress function(int $processed, int $created, int $updated): void
* @return array{created:int, updated:int}
*/
public static function sync_all(int $batch_size = 200, ?callable $on_progress = null): array
{
$created = 0;
$updated = 0;
$processed = 0;
$offset = 0;
while (true) {
$works = Work::all_paginated($batch_size, $offset);
if (empty($works)) {
break;
}
foreach ($works as $work) {
$is_new = empty($work->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);
}
}
}
@@ -0,0 +1,107 @@
<?php
namespace Bookstore\Core;
defined('ABSPATH') || exit;
/**
* Catalog schema per design doc §02/§05/§06/§07. LONGTEXT is used instead
* of the JSON column type for `subjects` — dbDelta has a history of
* mis-parsing JSON column definitions, and MariaDB's JSON type is just a
* LONGTEXT alias with a CHECK constraint anyway.
*/
class Schema
{
public static function install(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$prefix = $wpdb->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();
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace Bookstore\Core\Shipping;
defined('ABSPATH') || exit;
/**
* A real WC_Shipping_Method, not WooCommerce's built-in Free Shipping —
* that only supports one static threshold per instance, and design doc §07
* needs two different ones depending on membership. Thresholds are stored
* as options (bsc_shipping_threshold_guest/_member), not hardcoded, so the
* launch gate's automated test can assert against the same source this
* reads from.
*/
class BSC_Free_Shipping extends \WC_Shipping_Method
{
public function __construct($instance_id = 0)
{
$this->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');
}
}