diff --git a/README.md b/README.md index 160b154..8510a91 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ pre-launch plan and the technical design doc (`bookstore-core` schema, interfaces, order state machine — the design doc is the source of truth for *why* this repo is laid out the way it is). -This week's scope (Week 1 of the 5-week plan): environments, deploy -pipeline, and the WooCommerce/HPOS plugin scaffold. Catalog schema, pricing, -supplier adapters, and the order state machine are Week 2+ and live under -`wp-content/plugins/bookstore-core/includes/`, currently empty. +Weeks 1–2 of the 5-week plan are done: environments, deploy pipeline, the +WooCommerce/HPOS plugin scaffold, catalog schema, Gutenberg importer, +pricing engine, and membership shipping all live under +`wp-content/plugins/bookstore-core/includes/`. Checkout, the order state +machine, and live supplier integrations are Week 3+. ## Layout @@ -97,6 +98,31 @@ After that, the starter site's content and Customizer settings persist in the database like any other WordPress content — a redeploy or a fresh `deploy.sh` run doesn't touch it or need to repeat it. +### Payments: Helcim + +`deploy.sh` installs and activates the free, wordpress.org-hosted **Helcim +for WooCommerce** plugin (`helcim-commerce-for-woocommerce`) automatically +in every environment — nothing to do here for that part. + +The plugin needs a Helcim **sandbox API token**, which isn't something a +public API can hand out, so it's a one-time manual step per environment: + +1. Sign up for a free Helcim account. +2. Helcim dashboard → **Integrations → API Configuration** → create an API + token. +3. Put it in `HELCIM_API_TOKEN` in `.env.`, then re-run + `make deploy ENV=`. + +`deploy.sh` writes the token to a gitignored secrets file the same way it +already does for `HARDCOVER_API_TOKEN` (see `secrets//` above), and +`Payments\HelcimConfig::ensure_configured_from_env()` seeds the gateway's +WooCommerce settings (sandbox mode, disabled by default — a human still +enables it at checkout once ready) the first time it finds a token. Until a +token exists, the plugin is installed and inert; nothing else in this repo +is blocked on it. Go-live (switching the gateway from sandbox to Helcim's +live environment) is a deliberate manual step on the settings screen, not +something this repo automates. + ## Deploy pipeline The Git server (Gitea) and the staging/production Docker hosts are separate @@ -140,10 +166,58 @@ run a real restore drill: `restore.sh` only ever targets staging — there's no production argument, so a restore drill can't accidentally overwrite the live site. +## Catalog pipeline (Week 2) + +Staging data is built with `wp bookstore` sub-commands (see `wp bookstore +help` for full options): + +``` +wp bookstore import-gutenberg --limit=10000 # Work/Edition/ISBN rows from the real PG catalog feed +wp bookstore generate-offers # synthetic SupplierOffer rows: stock, condition, price +wp bookstore sync-products # one WooCommerce product per Work +wp bookstore sync-covers --limit=20 # optional: real cover art via Open Library +wp bookstore sync-hardcover-tags --limit=20 # optional: genre/mood/content-warning tags +``` + +`import-gutenberg --limit` counts only *new* titles per run, so re-running +with a bigger `--limit` continues forward instead of rescanning. Pricing +(25% target margin) and the $25/$15 free-member shipping thresholds are in +`includes/Pricing/PricingEngine.php` and +`includes/Shipping/BSC_Free_Shipping.php`. + +## Checkout + order engine (Week 3) + +Helcim's WooCommerce plugin is installed (see "Payments: Helcim" above), +paired with a synchronous pre-charge supplier/margin recheck and a +fraud-check hook — both run right before the payment gateway is called +(`Order\CheckoutRecheck`), never after. A passing order moves through a +custom status sequence beyond WooCommerce's own pending/processing/ +completed: `bsc-ordered` (submitted to the supplier via Action +Scheduler, `Order\SupplierOrderJob`) → `bsc-shipped` → `completed`. Any +failure at any stage — no sellable offer, margin no longer holds, fraud +check fails, or the supplier submission itself fails after charging — lands +the order in `bsc-exception` ("Needs Review"), which is the review queue: +just a filterable WooCommerce order status, not a separate table. A +post-charge failure also triggers an automatic refund +(`wc_create_refund(..., refund_payment: true)`), per the design doc's +Appendix B decision that an undo is always a refund, never a void. + +`wp bookstore force-offer-status ` (see +`wp bookstore help`) forces a specific ISBN's synthetic offer into a +specific state — `SyntheticOfferGenerator`'s rolls are random, so this is +how to reliably exercise the recheck's failure paths (price-increase, +out-of-stock, discontinued-after-charge) in a real checkout. + +Real BooksRun/Ingram supplier adapters are still Week 4 — `SupplierOrderJob` +runs against the synthetic `gutenberg_test` supplier for now, deliberately +built through the same Action Scheduler/locking path Week 4's real +integration will reuse rather than restructure. + ## What's deliberately not here yet -- Catalog tables, pricing engine, supplier adapters, order state machine — - Week 2–4, see the design doc. +- Live BooksRun/Ingram supplier adapters — Week 4, see the design doc. +- Automated tests for the pricing/shipping-threshold/checkout-recheck + logic — called for by the design doc's launch gates, not yet written. - A real TLS-terminating public domain — Caddy will auto-provision certs once `SITE_DOMAIN` points at a real host with 80/443 reachable; until then `docker-compose.dev.yml` is the only one that works from diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 975d14d..f8f7082 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -67,12 +67,20 @@ printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password" # exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent). HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)" printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token" +# Same reasoning/pattern as Hardcover above — optional, written unconditionally. +# Payments\HelcimConfig::is_configured() is false (and the gateway stays +# unconfigured) until a real sandbox token lands in HELCIM_API_TOKEN. +HELCIM_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HELCIM_API_TOKEN)" +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 # 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" +chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token" "$SECRETS_DIR/helcim_api_token" echo "==> building and starting ${ENVIRONMENT}" $COMPOSE up -d --build @@ -104,7 +112,7 @@ echo "==> installing/activating theme (Blocksy — free; Book Store starter site $WP theme install blocksy --activate echo "==> installing/activating required plugins" -$WP plugin install woocommerce redis-cache blocksy-companion --activate +$WP plugin install woocommerce redis-cache blocksy-companion helcim-commerce-for-woocommerce --activate $WP plugin activate bookstore-core $WP redis enable || true diff --git a/docker-compose.yml b/docker-compose.yml index 65e62ab..a294175 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,11 +22,11 @@ # bind mount never touches the file's content, only its path, so it's # immune to this entirely. # -# The remaining supplier/payment 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 -# now does below. +# 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. x-bookstore-env: &bookstore-env WORDPRESS_DB_HOST: db WORDPRESS_DB_NAME: ${DB_NAME} @@ -45,10 +45,10 @@ x-bookstore-env: &bookstore-env BOOKSRUN_API_KEY: ${BOOKSRUN_API_KEY:-} INGRAM_API_KEY: ${INGRAM_API_KEY:-} INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-} - HELCIM_API_TOKEN: ${HELCIM_API_TOKEN:-} 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 services: db: @@ -98,6 +98,7 @@ services: - ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins - ./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 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 @@ -152,6 +153,7 @@ services: - ./docker/cron/entrypoint.sh:/usr/local/bin/cron-entrypoint.sh:ro - ./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 volumes: db_data: diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index 3f7fc7e..7e72c96 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -13,7 +13,7 @@ defined('ABSPATH') || exit; define('BSC_PLUGIN_FILE', __FILE__); define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('BSC_VERSION', '0.2.0'); -define('BSC_SCHEMA_VERSION', '1'); +define('BSC_SCHEMA_VERSION', '2'); spl_autoload_register(function (string $class) { $prefix = 'Bookstore\\Core\\'; @@ -51,6 +51,7 @@ register_activation_hook(__FILE__, function () { add_action('plugins_loaded', function () { \Bookstore\Core\Schema::maybe_upgrade(); + \Bookstore\Core\Payments\HelcimConfig::ensure_configured_from_env(); }); add_filter('woocommerce_shipping_methods', function (array $methods) { @@ -64,6 +65,25 @@ add_action('init', function () { \Bookstore\Core\Taxonomy\TagDisplay::register(); +// Week 3: checkout + order engine (design doc §04/Appendix B). +\Bookstore\Core\Order\OrderStatuses::register(); +\Bookstore\Core\Order\CheckoutRecheck::register(); +\Bookstore\Core\Order\SupplierOrderJob::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(); + return $emails; +}); + +// Appendix B: "the store loses only the processing fee" — recorded +// against the order-audit ledger for every refund, whether triggered by +// an admin's manual refund click or SupplierOrderJob's automatic +// post-charge-failure refund. +add_action('woocommerce_order_refunded', function (int $order_id, int $refund_id) { + \Bookstore\Core\Order\AuditTrail::record_refund($order_id); +}, 10, 2); + 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/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php index af0cd44..3ce767d 100644 --- a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -3,6 +3,7 @@ namespace Bookstore\Core\Cli; use Bookstore\Core\Catalog\Isbn; +use Bookstore\Core\Catalog\SupplierOffer; use Bookstore\Core\Catalog\Work; use Bookstore\Core\Import\GutenbergImporter; use Bookstore\Core\Import\SyntheticOfferGenerator; @@ -118,6 +119,70 @@ class Commands )); } + /** + * Forces a specific ISBN's supplier offer into an explicit state, for + * reproducible checkout-recheck testing. SyntheticOfferGenerator's + * status/stock rolls are random (Week 2), so there's otherwise no way + * to reliably force one ISBN into e.g. "just went out of stock" for a + * Week 3 checkout test. + * + * ## OPTIONS + * + * + * : ISBN-13 to force. + * + * + * : One of: active, stale, discontinued. + * + * [--stock=] + * : Stock quantity (default: 10 for active, 0 for stale/discontinued). + * + * [--base-price=] + * : Supplier base price (default 5.00). + * + * [--supplier-ship=] + * : Supplier shipping cost (default 2.00). + * + * ## EXAMPLES + * + * wp bookstore force-offer-status 9781234567890 active --base-price=99.00 + * wp bookstore force-offer-status 9781234567890 discontinued + * + * @subcommand force-offer-status + */ + public function force_offer_status($args, $assoc_args) + { + [$isbn13, $status] = $args; + if (!in_array($status, ['active', 'stale', 'discontinued'], true)) { + \WP_CLI::error('status must be one of: active, stale, discontinued'); + } + + $stock = isset($assoc_args['stock']) ? (int) $assoc_args['stock'] : ($status === 'active' ? 10 : 0); + $base_price = (float) ($assoc_args['base-price'] ?? 5.00); + $supplier_ship = (float) ($assoc_args['supplier-ship'] ?? 2.00); + + SupplierOffer::delete_for_isbn($isbn13); + SupplierOffer::insert( + $isbn13, + 'gutenberg_test', + 'good', + $stock, + $base_price, + $supplier_ship, + $status, + gmdate('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS) + ); + + \WP_CLI::success(sprintf( + 'Forced offer for %s: status=%s, stock=%d, base_price=%.2f, supplier_ship=%.2f.', + $isbn13, + $status, + $stock, + $base_price, + $supplier_ship + )); + } + /** * Syncs genre/mood/content-warning/freeform tags from Hardcover onto * each Work's product (design doc-adjacent: AO3-style tag browsing). diff --git a/wp-content/plugins/bookstore-core/includes/Email/ExceptionReviewEmail.php b/wp-content/plugins/bookstore-core/includes/Email/ExceptionReviewEmail.php new file mode 100644 index 0000000..c47861d --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Email/ExceptionReviewEmail.php @@ -0,0 +1,78 @@ +id = 'bsc_exception_review'; + $this->title = 'Order Needs Review'; + $this->description = 'Sent to the store admin whenever an order is routed into the exception/review queue.'; + $this->customer_email = false; + $this->heading = 'Order Needs Review'; + $this->subject = '[{site_title}] Order #{order_number} needs review'; + $this->template_html = 'emails/bsc-exception-review-html.php'; + $this->template_plain = 'emails/bsc-exception-review-plain.php'; + $this->template_base = BSC_PLUGIN_DIR . 'templates/'; + + add_action('woocommerce_order_status_' . \Bookstore\Core\Order\OrderStatuses::EXCEPTION, [$this, 'trigger'], 10, 2); + + parent::__construct(); + $this->recipient = $this->get_option('recipient', get_option('admin_email')); + } + + public function trigger($order_id, $order = null): void + { + $this->setup_locale(); + + if ($order_id && !$order instanceof \WC_Order) { + $order = wc_get_order($order_id); + } + if ($order instanceof \WC_Order) { + $this->object = $order; + $this->recipient = $this->get_option('recipient', get_option('admin_email')); + $this->placeholders['{order_number}'] = $this->object->get_order_number(); + } + + if ($this->is_enabled() && $this->get_recipient()) { + $this->send($this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments()); + } + + $this->restore_locale(); + } + + public function get_content_html() + { + return wc_get_template_html($this->template_html, [ + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + 'additional_content' => $this->get_additional_content(), + 'sent_to_admin' => true, + 'plain_text' => false, + 'email' => $this, + ], '', $this->template_base); + } + + public function get_content_plain() + { + return wc_get_template_html($this->template_plain, [ + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + 'additional_content' => $this->get_additional_content(), + 'sent_to_admin' => true, + 'plain_text' => true, + 'email' => $this, + ], '', $this->template_base); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Email/ShippedEmail.php b/wp-content/plugins/bookstore-core/includes/Email/ShippedEmail.php new file mode 100644 index 0000000..7f56247 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Email/ShippedEmail.php @@ -0,0 +1,77 @@ +id = 'bsc_shipped'; + $this->title = 'Order Shipped'; + $this->description = 'Sent to the customer once their order is marked Shipped.'; + $this->customer_email = true; + $this->heading = 'Your order has shipped!'; + $this->subject = 'Your {site_title} order #{order_number} has shipped'; + $this->template_html = 'emails/bsc-shipped-html.php'; + $this->template_plain = 'emails/bsc-shipped-plain.php'; + $this->template_base = BSC_PLUGIN_DIR . 'templates/'; + + add_action('woocommerce_order_status_' . \Bookstore\Core\Order\OrderStatuses::SHIPPED, [$this, 'trigger'], 10, 2); + + parent::__construct(); + } + + public function trigger($order_id, $order = null): void + { + $this->setup_locale(); + + if ($order_id && !$order instanceof \WC_Order) { + $order = wc_get_order($order_id); + } + if ($order instanceof \WC_Order) { + $this->object = $order; + $this->recipient = $this->object->get_billing_email(); + $this->placeholders['{order_number}'] = $this->object->get_order_number(); + } + + if ($this->is_enabled() && $this->get_recipient()) { + $this->send($this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments()); + } + + $this->restore_locale(); + } + + public function get_content_html() + { + return wc_get_template_html($this->template_html, [ + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + 'additional_content' => $this->get_additional_content(), + 'sent_to_admin' => false, + 'plain_text' => false, + 'email' => $this, + ], '', $this->template_base); + } + + public function get_content_plain() + { + return wc_get_template_html($this->template_plain, [ + 'order' => $this->object, + 'email_heading' => $this->get_heading(), + 'additional_content' => $this->get_additional_content(), + 'sent_to_admin' => false, + 'plain_text' => true, + 'email' => $this, + ], '', $this->template_base); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php b/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php new file mode 100644 index 0000000..b39e84c --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php @@ -0,0 +1,78 @@ +prefix . 'bsc_order_audit'; + } + + public static function insert( + int $order_id, + ?int $order_item_id, + int $work_id, + int $offer_id, + string $supplier_code, + float $supplier_cost, + float $supplier_shipping, + float $processor_cost_expected, + float $selling_price, + float $expected_contribution + ): int { + global $wpdb; + $result = $wpdb->insert(self::table(), [ + 'order_id' => $order_id, + 'order_item_id' => $order_item_id, + 'work_id' => $work_id, + 'offer_id' => $offer_id, + 'supplier_code' => $supplier_code, + 'supplier_cost' => $supplier_cost, + 'supplier_shipping' => $supplier_shipping, + 'processor_cost_expected' => $processor_cost_expected, + 'selling_price' => $selling_price, + 'expected_contribution' => $expected_contribution, + 'created_at' => current_time('mysql', true), + ]); + if ($result === false) { + throw new \RuntimeException('bsc_order_audit insert failed: ' . $wpdb->last_error . " (order_id: {$order_id})"); + } + return (int) $wpdb->insert_id; + } + + /** @return object[] */ + public static function for_order(int $order_id): array + { + global $wpdb; + return $wpdb->get_results($wpdb->prepare( + "SELECT * FROM " . self::table() . " WHERE order_id = %d", + $order_id + )); + } + + /** + * A full refund (Appendix B: "the store loses only the processing + * fee") recovers nothing else — record that as the only real loss + * against the expectation this row already captured at checkout time. + */ + public static function record_refund(int $order_id): void + { + global $wpdb; + $wpdb->query($wpdb->prepare( + "UPDATE " . self::table() . " + SET processor_cost_actual = processor_cost_expected, + actual_contribution = -processor_cost_expected + WHERE order_id = %d", + $order_id + )); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php b/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php new file mode 100644 index 0000000..af046e6 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php @@ -0,0 +1,244 @@ +cart || WC()->cart->is_empty()) { + return; + } + + if (!self::acquire_lock()) { + $errors->add( + 'bsc-recheck-lock', + 'Your previous checkout attempt for this cart is still processing — please wait a moment and try again.' + ); + return; + } + + $failed = false; + foreach (WC()->cart->get_cart() as $cart_item) { + $work_id = PriceRecheck::work_id_for_product((int) $cart_item['product_id']); + if (!$work_id) { + continue; // not a bookstore-core-managed product — nothing to recheck + } + + $recheck = PriceRecheck::recheck_for_work($work_id); + $quoted = (float) $cart_item['data']->get_price(); + + if (!$recheck || $recheck->price > $quoted + self::PRICE_CUSHION) { + $errors->add( + 'bsc-recheck', + sprintf( + 'Sorry, "%s" is no longer available at its listed price. Please refresh your cart and try again.', + $cart_item['data']->get_name() + ) + ); + $failed = true; + } + } + + if ($failed) { + // finalize_order() will never fire for this request — this hook + // is the only place that can release the lock. + self::release_lock(); + } + // On success the lock is deliberately left held: finalize_order() + // (the next hook in the same request, classic flow only) releases + // it in a finally block, whether it passes or throws. + } + + /** Classic checkout only — order exists, runs right before payment. */ + public static function finalize_order(int $order_id, array $posted_data, \WC_Order $order): void + { + try { + $failure = self::run_checks($order); + if ($failure !== null) { + throw new \Exception($failure); + } + } finally { + self::release_lock(); + } + } + + /** + * WooCommerce Blocks / Store API checkout — the path this site's real + * storefront actually runs. Self-contained: acquires and releases its + * own lock in one call, since Blocks checkout has no earlier hook to + * split the lock across the way the classic pair above does. + * + * KNOWN LIMITATION, confirmed by firing two genuinely concurrent + * checkout POSTs at the same session in testing: Store API creates an + * independent draft order per request rather than one shared draft + * order per cart, so two overlapping submissions can each pass this + * hook on their OWN order object and both go on to charge — this lock + * only serializes the recheck itself (so two requests can't both read + * a fresh-but-about-to-be-stale offer at the same instant), it does + * NOT collapse two overlapping submissions of the same cart into one + * order. Closing that fully would need a client-supplied idempotency + * key checked at order-creation time (the standard fix for this class + * of problem), which touches the checkout block's frontend — out of + * scope here. What this DOES fully solve, proven directly: duplicate + * *supplier-submission* execution (SupplierOrderJob's own meta+lock + * guards — see that class), which is what the design doc's "duplicate + * webhook/job... do not double-order" requirement is actually about. + */ + public static function validate_order_before_payment(\WC_Order $order, \WP_Error $validation_errors): void + { + if (!self::acquire_lock()) { + $validation_errors->add( + 'bsc-recheck-lock', + 'Your previous checkout attempt for this cart is still processing — please wait a moment and try again.' + ); + return; + } + + try { + $failure = self::run_checks($order); + if ($failure !== null) { + $validation_errors->add('bsc-recheck', $failure); + } + } finally { + self::release_lock(); + } + } + + /** + * Shared by both checkout implementations. Returns null on success + * (audit rows already written, fraud check already logged) or a + * customer-facing failure message (order already routed to the + * exception status) on failure. Audit rows are only ever inserted + * after every item AND the fraud check have passed — never partially, + * which matters because a failed attempt can be resubmitted against + * the same draft order under Blocks checkout. + */ + private static function run_checks(\WC_Order $order): ?string + { + $pending_audit_rows = []; + + foreach ($order->get_items() as $item) { + $work_id = PriceRecheck::work_id_for_product($item->get_product_id()); + if (!$work_id) { + continue; + } + + $recheck = PriceRecheck::recheck_for_work($work_id); + $quantity = max(1, $item->get_quantity()); + $quoted_unit = (float) $item->get_total() / $quantity; + + if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) { + self::route_to_exception($order, sprintf( + 'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).', + $item->get_name(), + $quoted_unit, + $recheck->price ?? 0.0 + )); + return 'Sorry, part of your order is no longer available at its listed price. You have not been charged — please try again.'; + } + + $offer = $recheck->offer; + $selling_price = (float) $item->get_total(); + $supplier_cost = (float) $offer->base_price * $quantity; + $supplier_shipping = (float) $offer->supplier_ship * $quantity; + $processor_cost_expected = PricingEngine::estimate_processing_cost($selling_price); + + $pending_audit_rows[] = [ + $order->get_id(), + $item->get_id(), + $work_id, + (int) $offer->offer_id, + (string) $offer->supplier_code, + $supplier_cost, + $supplier_shipping, + $processor_cost_expected, + $selling_price, + $selling_price - $supplier_cost - $supplier_shipping - $processor_cost_expected, + ]; + } + + $fraud = FraudCheck::run($order); + if (!$fraud['pass']) { + self::route_to_exception($order, 'Fraud check failed: ' . $fraud['reason']); + return 'Your order could not be processed and has been placed under review. You have not been charged.'; + } + + foreach ($pending_audit_rows as $row) { + AuditTrail::insert(...$row); + } + + return null; + } + + private static function route_to_exception(\WC_Order $order, string $reason): void + { + $order->update_status(OrderStatuses::EXCEPTION, $reason); + } + + private static function lock_key(): string + { + $identity = WC()->session ? WC()->session->get_customer_id() : (string) session_id(); + return 'bsc_checkout_' . md5((string) $identity); + } + + private static function acquire_lock(): bool + { + global $wpdb; + return (bool) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', self::lock_key())); + } + + private static function release_lock(): void + { + global $wpdb; + $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', self::lock_key())); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php b/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php new file mode 100644 index 0000000..65a9cc0 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php @@ -0,0 +1,106 @@ + Supplier Verify -> + * Margin Verify -> Fraud Check -> Charged -> Supplier Ordered -> Shipped + * -> Complete. Pending/Charged/Complete map onto WooCommerce's own + * pending/processing/completed; Supplier Verify, Margin Verify, and Fraud + * Check all complete within one request (CheckoutRecheck) and are logged + * as order notes rather than full status transitions — a transition per + * millisecond-scale sub-step would register three more email/report + * buckets for no observable benefit. What *does* need a real, persisted, + * filterable status is "every failure path must end in a visible review + * state" — hence exactly one new failure status (bsc-exception) plus the + * two remaining forward-progress steps the state machine names + * (bsc-supplier-ordered, bsc-shipped). + * + * register_post_status()/the wc_order_statuses filter is the standard + * WooCommerce recipe for adding a custom order status and is unaffected + * by HPOS — HPOS only changes where an order's data is stored, not how + * the status registry itself works. + */ +class OrderStatuses +{ + public const EXCEPTION = 'bsc-exception'; + // "wc-" + slug must fit post_status/wp_wc_orders.status's VARCHAR(20) — + // confirmed the hard way: 'wc-bsc-supplier-ordered' (23 chars) silently + // truncated to 'wc-bsc-supplier-orde' in this DB's non-strict sql_mode, + // which get_status() then displayed with its 'wc-' prefix stripped as + // the nonsensical 'bsc-supplier-orde'. 'wc-bsc-ordered' is 14 chars. + public const SUPPLIER_ORDERED = 'bsc-ordered'; + public const SHIPPED = 'bsc-shipped'; + + public static function register(): void + { + add_action('init', [self::class, 'register_post_statuses']); + add_filter('wc_order_statuses', [self::class, 'add_to_order_statuses']); + } + + public static function register_post_statuses(): void + { + register_post_status('wc-' . self::EXCEPTION, [ + 'label' => 'Needs Review', + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + 'label_count' => self::noop_label_count('Needs Review'), + ]); + + register_post_status('wc-' . self::SUPPLIER_ORDERED, [ + 'label' => 'Supplier Ordered', + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + 'label_count' => self::noop_label_count('Supplier Ordered'), + ]); + + register_post_status('wc-' . self::SHIPPED, [ + 'label' => 'Shipped', + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + 'label_count' => self::noop_label_count('Shipped'), + ]); + } + + public static function add_to_order_statuses(array $statuses): array + { + $with_new = []; + foreach ($statuses as $key => $label) { + $with_new[$key] = $label; + if ($key === 'wc-processing') { + $with_new['wc-' . self::SUPPLIER_ORDERED] = 'Supplier Ordered'; + $with_new['wc-' . self::SHIPPED] = 'Shipped'; + } + } + $with_new['wc-' . self::EXCEPTION] = 'Needs Review'; + return $with_new; + } + + /** + * register_post_status()'s 'label_count' expects the array shape + * _n_noop() produces (singular/plural/context/domain) — built directly + * rather than via _n_noop() since this plugin doesn't ship a text + * domain/.pot file anywhere else either (matches the rest of the + * codebase, which uses plain strings throughout). + */ + private static function noop_label_count(string $label): array + { + $markup = $label . ' (%s)'; + return [ + 0 => $markup, + 1 => $markup, + 'singular' => $markup, + 'plural' => $markup, + 'context' => null, + 'domain' => null, + ]; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php b/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php new file mode 100644 index 0000000..8ce8b42 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Order/PriceRecheck.php @@ -0,0 +1,51 @@ + $offer, + 'price' => PricingEngine::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship), + ]; + } + + /** + * ProductSync stamps every synced product with its owning Work's id + * (Product\ProductSync::sync_one()) — reused here rather than adding a + * second work-for-product lookup query. + */ + public static function work_id_for_product(int $product_id): ?int + { + $work_id = get_post_meta($product_id, '_bsc_work_id', true); + return $work_id !== '' ? (int) $work_id : null; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php b/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php new file mode 100644 index 0000000..47776d3 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php @@ -0,0 +1,127 @@ + retry + * safely via queue; do not double-order" idempotency requirement actually + * lives, ahead of Week 4 needing it for real. + */ +class SupplierOrderJob +{ + public static function register(): void + { + add_action('woocommerce_payment_complete', [self::class, 'schedule_for_order']); + add_action('bsc_submit_supplier_order', [self::class, 'run']); + } + + /** + * woocommerce_payment_complete is documented to sometimes fire more + * than once for the same order depending on gateway/flow — this guard + * (checked again, more strongly, inside run() itself) is what stops a + * duplicate firing from scheduling the submission twice. + */ + public static function schedule_for_order(int $order_id): void + { + $order = wc_get_order($order_id); + if (!$order || $order->get_meta('_bsc_supplier_order_key')) { + return; + } + + $order->update_meta_data('_bsc_supplier_order_key', wp_generate_uuid4()); + $order->save(); + + as_schedule_single_action(time(), 'bsc_submit_supplier_order', ['order_id' => $order_id], 'bsc-orders'); + } + + public static function run(int $order_id): void + { + global $wpdb; + $lock_key = 'bsc_supplier_order_' . $order_id; + + // Belt-and-suspenders over schedule_for_order()'s meta guard: covers + // two Action Scheduler rows somehow both getting scheduled, or AS's + // own retry of a stalled action racing a still-running attempt. + if (!$wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 10)', $lock_key))) { + return; + } + + try { + $order = wc_get_order($order_id); + if (!$order || $order->get_meta('_bsc_supplier_order_status') === 'submitted') { + return; + } + + foreach (AuditTrail::for_order($order_id) as $audit_row) { + $offer = $wpdb->get_row($wpdb->prepare( + "SELECT 1 FROM " . SupplierOffer::table() . " + WHERE offer_id = %d AND status = 'active' AND stock_qty > 0", + $audit_row->offer_id + )); + + if (!$offer) { + $order->update_meta_data('_bsc_supplier_order_status', 'failed'); + $order->save(); + self::fail_and_refund( + $order, + "Pinned supplier offer #{$audit_row->offer_id} is no longer sellable at fulfillment time." + ); + return; + } + } + + $order->add_order_note('Supplier order submitted (synthetic: gutenberg_test).'); + $order->update_meta_data('_bsc_supplier_order_status', 'submitted'); + $order->save(); + $order->update_status(OrderStatuses::SUPPLIER_ORDERED); + } finally { + $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key)); + } + } + + /** + * Appendix B residual risk, made concrete: "a charge that later needs + * to be undone... becomes a refund instead of a void. The customer is + * made whole in full; the store loses only the processing fee." + * refund_payment=true is what makes wc_create_refund() actually invoke + * the gateway's process_refund() rather than just recording a line + * item. + * + * wc_create_refund() returns a WP_Error — not false, not a thrown + * exception — when the active gateway doesn't support automatic + * refunds (confirmed directly: WooCommerce's own stock Cheque/BACS/COD + * gateways all lack process_refund(), and this is exactly the gateway + * used to test this path without real Helcim credentials yet). Left + * unchecked, the refund silently never happens with no trace anywhere + * — the order still reaches bsc-exception, but "every failure path + * must end in a visible review state" needs the refund's own outcome + * to be visible too, not just the parent order state. + */ + private static function fail_and_refund(\WC_Order $order, string $reason): void + { + $order->update_status(OrderStatuses::EXCEPTION, $reason); + + $refund = wc_create_refund([ + 'order_id' => $order->get_id(), + 'amount' => (float) $order->get_total(), + 'reason' => $reason, + 'refund_payment' => true, + ]); + + if (is_wp_error($refund)) { + $order->add_order_note( + 'Automatic refund could not be issued (' . $refund->get_error_message() . ') — needs a manual refund.' + ); + } + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php b/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php new file mode 100644 index 0000000..ce0606c --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php @@ -0,0 +1,34 @@ + true, + 'reason' => 'no-op: fraud plugin not installed', + ], $order); + + $order->add_order_note( + 'Fraud check: ' . ($result['pass'] ? 'passed' : 'FAILED') . ' — ' . $result['reason'] + ); + + return $result; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/HelcimConfig.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimConfig.php new file mode 100644 index 0000000..5ee78d5 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/HelcimConfig.php @@ -0,0 +1,79 @@ +/environ`, a 644 file inside a 700 secrets/ dir + * isn't. + */ +class HelcimConfig +{ + private const OPTION_NAME = 'woocommerce_helcimjs_settings'; + + public static function is_configured(): bool + { + return self::token() !== null; + } + + private static function token(): ?string + { + $file = getenv('HELCIM_API_TOKEN_FILE'); + if (!$file || !is_readable($file)) { + return null; + } + $value = trim((string) file_get_contents($file)); + return $value !== '' ? $value : null; + } + + /** + * Fills in only what's still unset — never overwrites a value a human + * already changed by hand in wp-admin (e.g. a custom title/description, + * or a deliberate switch to 'live'). Only ever seeds the SANDBOX + * token: this repo has no production Helcim credentials, and going + * live is a deliberate separate decision a human makes on the + * settings screen, not something an env var should flip silently. + */ + public static function ensure_configured_from_env(): void + { + if (!class_exists(\Helcim\Gateway::class)) { + return; // plugin not installed/active + } + + $token = self::token(); + if ($token === null) { + return; + } + + $settings = get_option(self::OPTION_NAME, []); + if (!is_array($settings)) { + $settings = []; + } + + $settings += [ + 'enabled' => 'no', // a human opts in explicitly once ready to test live at checkout + 'title' => 'Credit Card', + 'description' => '', + 'environment' => 'sandbox', + 'enable_saved_cards' => 'no', + ]; + + if (empty($settings['sandbox_api_token'])) { + $settings['sandbox_api_token'] = $token; + } + + update_option(self::OPTION_NAME, $settings); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php b/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php index e139ea9..d2c5534 100644 --- a/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php +++ b/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php @@ -56,4 +56,18 @@ class PricingEngine } return self::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship); } + + /** + * Week 3: what calculate_retail_price() assumed Helcim's cut would be + * for a given selling price, for the order-audit ledger + * (Order\AuditTrail) to compare against actual settlement later. + * Exposed as its own method (rather than inlining the formula at every + * call site) so there is exactly one place PROCESSING_RATE/ + * FIXED_PROCESSING get read once real settlement data replaces the + * placeholder — see this class's docblock. + */ + public static function estimate_processing_cost(float $selling_price): float + { + return round($selling_price * self::PROCESSING_RATE + self::FIXED_PROCESSING, 2); + } } diff --git a/wp-content/plugins/bookstore-core/includes/Schema.php b/wp-content/plugins/bookstore-core/includes/Schema.php index 7789057..db68ab0 100644 --- a/wp-content/plugins/bookstore-core/includes/Schema.php +++ b/wp-content/plugins/bookstore-core/includes/Schema.php @@ -91,6 +91,33 @@ class Schema PRIMARY KEY (user_id) ) {$charset_collate};"; + // Week 3 (design doc Appendix B / §08 audit data): one row per + // order line, pinning the exact offer charged against so a later + // supplier-submission retry (Order\SupplierOrderJob) targets the + // same offer rather than re-resolving best_offer_for_isbns() a + // third time, and so margin can eventually be tuned from real + // expected-vs-actual contribution instead of guessing. + $sql[] = "CREATE TABLE {$prefix}order_audit ( + audit_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + order_item_id BIGINT UNSIGNED NULL, + work_id BIGINT UNSIGNED NOT NULL, + offer_id BIGINT UNSIGNED NOT NULL, + supplier_code VARCHAR(20) NOT NULL, + supplier_cost DECIMAL(10,2) NOT NULL, + supplier_shipping DECIMAL(10,2) NOT NULL, + processor_cost_expected DECIMAL(10,2) NOT NULL, + processor_cost_actual DECIMAL(10,2) NULL, + selling_price DECIMAL(10,2) NOT NULL, + expected_contribution DECIMAL(10,2) NOT NULL, + actual_contribution DECIMAL(10,2) NULL, + recheck_status VARCHAR(20) NOT NULL DEFAULT 'ok', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (audit_id), + KEY order_id (order_id), + KEY offer_id (offer_id) + ) {$charset_collate};"; + foreach ($sql as $statement) { dbDelta($statement); } diff --git a/wp-content/plugins/bookstore-core/templates/emails/bsc-exception-review-html.php b/wp-content/plugins/bookstore-core/templates/emails/bsc-exception-review-html.php new file mode 100644 index 0000000..f729208 --- /dev/null +++ b/wp-content/plugins/bookstore-core/templates/emails/bsc-exception-review-html.php @@ -0,0 +1,40 @@ + +

+ get_order_number()) + ); + ?> +

+

Most recent order note (reason):

+
+ $order->get_id(), 'limit' => 1]); + echo $notes ? wp_kses_post($notes[0]->content) : 'No note found.'; + ?> +
+ +get_order_number() +); + +$notes = wc_get_order_notes(['order_id' => $order->get_id(), 'limit' => 1]); +echo "Most recent order note (reason):\n"; +echo ($notes ? wp_strip_all_tags($notes[0]->content) : 'No note found.') . "\n\n"; + +echo "----------------------------------------\n\n"; + +do_action('woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email); +do_action('woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email); +do_action('woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email); + +if ($additional_content) { + echo wp_strip_all_tags(wptexturize($additional_content)) . "\n\n"; +} diff --git a/wp-content/plugins/bookstore-core/templates/emails/bsc-shipped-html.php b/wp-content/plugins/bookstore-core/templates/emails/bsc-shipped-html.php new file mode 100644 index 0000000..7f1c40a --- /dev/null +++ b/wp-content/plugins/bookstore-core/templates/emails/bsc-shipped-html.php @@ -0,0 +1,28 @@ + +

+ get_order_number())); ?> +

+ +get_order_number()); + +echo "----------------------------------------\n\n"; + +do_action('woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email); +do_action('woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email); +do_action('woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email); + +if ($additional_content) { + echo wp_strip_all_tags(wptexturize($additional_content)) . "\n\n"; +}