Week 3: checkout + order engine (Helcim gateway, pre-charge recheck, order state machine)

Wires up Helcim's free WooCommerce plugin (installed inert until a sandbox
token exists) alongside a synchronous pre-charge supplier/margin recheck
and a fraud-check hook, both firing immediately before payment per the
Appendix B immediate-charge decision. Adds a custom order-status sequence
(bsc-ordered/bsc-shipped/bsc-exception) as the state machine and review
queue, an Action Scheduler-backed supplier-submission job with idempotency
guards, a bsc_order_audit ledger, and two new transactional emails.

Hooks both the classic WooCommerce checkout actions and the real
woocommerce_checkout_validate_order_before_payment hook, since this site's
storefront runs WooCommerce Blocks/Store API rather than the classic
shortcode checkout (confirmed by reading WooCommerce's own source after
the classic hooks turned out not to fire in live testing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2gmgaDVXcRGCwLfdXQajD
This commit is contained in:
twooeyandClaude Sonnet 5 committed 2026-08-27 20:03:32 -04:00
1 parent 73c64d94c1
commit 4079bfceac
20 files changed
+1224 -15

No files matched your search

+80 -6
View File
@@ -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 interfaces, order state machine — the design doc is the source of truth for
*why* this repo is laid out the way it is). *why* this repo is laid out the way it is).
This week's scope (Week 1 of the 5-week plan): environments, deploy Weeks 1–2 of the 5-week plan are done: environments, deploy pipeline, the
pipeline, and the WooCommerce/HPOS plugin scaffold. Catalog schema, pricing, WooCommerce/HPOS plugin scaffold, catalog schema, Gutenberg importer,
supplier adapters, and the order state machine are Week 2+ and live under pricing engine, and membership shipping all live under
`wp-content/plugins/bookstore-core/includes/`, currently empty. `wp-content/plugins/bookstore-core/includes/`. Checkout, the order state
machine, and live supplier integrations are Week 3+.
## Layout ## 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 the database like any other WordPress content — a redeploy or a fresh
`deploy.sh` run doesn't touch it or need to repeat it. `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.<environment>`, then re-run
`make deploy ENV=<environment>`.
`deploy.sh` writes the token to a gitignored secrets file the same way it
already does for `HARDCOVER_API_TOKEN` (see `secrets/<env>/` 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 ## Deploy pipeline
The Git server (Gitea) and the staging/production Docker hosts are separate 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 `restore.sh` only ever targets staging — there's no production argument, so
a restore drill can't accidentally overwrite the live site. 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 <isbn13> <active|stale|discontinued>` (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 ## What's deliberately not here yet
- Catalog tables, pricing engine, supplier adapters, order state machine — - Live BooksRun/Ingram supplier adapters — Week 4, see the design doc.
Week 2–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 - A real TLS-terminating public domain — Caddy will auto-provision certs
once `SITE_DOMAIN` points at a real host with 80/443 reachable; until 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 then `docker-compose.dev.yml` is the only one that works from
+10 -2
View File
@@ -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). # exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent).
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)" HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/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 # 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 # (non-host-matching) container UID, e.g. www-data — chmod 600 made it
# unreadable to them. The containing directory (700, above) is what # unreadable to them. The containing directory (700, above) is what
# actually keeps other host users out; these just need to be world-readable # actually keeps other host users out; these just need to be world-readable
# within that already-restricted directory. # 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}" echo "==> building and starting ${ENVIRONMENT}"
$COMPOSE up -d --build $COMPOSE up -d --build
@@ -104,7 +112,7 @@ echo "==> installing/activating theme (Blocksy — free; Book Store starter site
$WP theme install blocksy --activate $WP theme install blocksy --activate
echo "==> installing/activating required plugins" 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 plugin activate bookstore-core
$WP redis enable || true $WP redis enable || true
+8 -6
View File
@@ -22,11 +22,11 @@
# bind mount never touches the file's content, only its path, so it's # bind mount never touches the file's content, only its path, so it's
# immune to this entirely. # immune to this entirely.
# #
# The remaining supplier/payment keys below are still passed the old ${VAR} # The remaining supplier keys below are still passed the old ${VAR} way and
# way and remain exposed to the same class of bug — nothing consumes them # remain exposed to the same class of bug — nothing consumes them yet
# yet (bookstore-core is still a stub for those), so when that code is # (bookstore-core is still a stub for those), so when that code is written
# written it should read `_FILE` variants the same way HARDCOVER_API_TOKEN # it should read `_FILE` variants the same way HARDCOVER_API_TOKEN and (as
# now does below. # of Week 3) HELCIM_API_TOKEN now do below.
x-bookstore-env: &bookstore-env x-bookstore-env: &bookstore-env
WORDPRESS_DB_HOST: db WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${DB_NAME} WORDPRESS_DB_NAME: ${DB_NAME}
@@ -45,10 +45,10 @@ x-bookstore-env: &bookstore-env
BOOKSRUN_API_KEY: ${BOOKSRUN_API_KEY:-} BOOKSRUN_API_KEY: ${BOOKSRUN_API_KEY:-}
INGRAM_API_KEY: ${INGRAM_API_KEY:-} INGRAM_API_KEY: ${INGRAM_API_KEY:-}
INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-} INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-}
HELCIM_API_TOKEN: ${HELCIM_API_TOKEN:-}
HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-}
MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-}
HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token
HELCIM_API_TOKEN_FILE: /run/secrets/helcim_api_token
services: services:
db: db:
@@ -98,6 +98,7 @@ services:
- ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins - ./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}/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}/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: healthcheck:
# No HTTP endpoint to hit directly — php-fpm speaks FastCGI on 9000, # 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 # 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 - ./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}/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}/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: volumes:
db_data: db_data:
@@ -13,7 +13,7 @@ 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.2.0'); define('BSC_VERSION', '0.2.0');
define('BSC_SCHEMA_VERSION', '1'); define('BSC_SCHEMA_VERSION', '2');
spl_autoload_register(function (string $class) { spl_autoload_register(function (string $class) {
$prefix = 'Bookstore\\Core\\'; $prefix = 'Bookstore\\Core\\';
@@ -51,6 +51,7 @@ register_activation_hook(__FILE__, function () {
add_action('plugins_loaded', function () { add_action('plugins_loaded', function () {
\Bookstore\Core\Schema::maybe_upgrade(); \Bookstore\Core\Schema::maybe_upgrade();
\Bookstore\Core\Payments\HelcimConfig::ensure_configured_from_env();
}); });
add_filter('woocommerce_shipping_methods', function (array $methods) { add_filter('woocommerce_shipping_methods', function (array $methods) {
@@ -64,6 +65,25 @@ add_action('init', function () {
\Bookstore\Core\Taxonomy\TagDisplay::register(); \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) { if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('bookstore', \Bookstore\Core\Cli\Commands::class); \WP_CLI::add_command('bookstore', \Bookstore\Core\Cli\Commands::class);
} }
@@ -3,6 +3,7 @@
namespace Bookstore\Core\Cli; namespace Bookstore\Core\Cli;
use Bookstore\Core\Catalog\Isbn; use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Catalog\Work; use Bookstore\Core\Catalog\Work;
use Bookstore\Core\Import\GutenbergImporter; use Bookstore\Core\Import\GutenbergImporter;
use Bookstore\Core\Import\SyntheticOfferGenerator; 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
*
* <isbn13>
* : ISBN-13 to force.
*
* <status>
* : One of: active, stale, discontinued.
*
* [--stock=<number>]
* : Stock quantity (default: 10 for active, 0 for stale/discontinued).
*
* [--base-price=<number>]
* : Supplier base price (default 5.00).
*
* [--supplier-ship=<number>]
* : 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 * Syncs genre/mood/content-warning/freeform tags from Hardcover onto
* each Work's product (design doc-adjacent: AO3-style tag browsing). * each Work's product (design doc-adjacent: AO3-style tag browsing).
@@ -0,0 +1,78 @@
<?php
namespace Bookstore\Core\Email;
defined('ABSPATH') || exit;
/**
* Admin-facing: fires whenever an order transitions into the exception
* queue (Order\OrderStatuses::EXCEPTION) for any reason — supplier
* recheck, margin recheck, fraud check, or post-charge supplier failure.
* Customer-facing failure is already communicated at checkout (an on-page
* notice) or via WooCommerce's stock refund email; this is purely the
* "someone needs to look at this" signal to the store operator.
*/
class ExceptionReviewEmail extends \WC_Email
{
public function __construct()
{
$this->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);
}
}
@@ -0,0 +1,77 @@
<?php
namespace Bookstore\Core\Email;
defined('ABSPATH') || exit;
/**
* Customer-facing: fires on transition into Order\OrderStatuses::SHIPPED.
* A real, separate email rather than reusing WooCommerce's stock
* "Completed order" email — the state machine (design doc §04) treats
* Shipped and Complete as two distinct, meaningfully different moments
* for the customer, and WooCommerce's own Completed email already covers
* the Complete step.
*/
class ShippedEmail extends \WC_Email
{
public function __construct()
{
$this->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);
}
}
@@ -0,0 +1,78 @@
<?php
namespace Bookstore\Core\Order;
defined('ABSPATH') || exit;
/**
* bsc_order_audit: one row per order line, written once at checkout
* (CheckoutRecheck::finalize_order()) and updated once on refund
* (record_refund()). Style mirrors Catalog\SupplierOffer exactly.
*/
class AuditTrail
{
public static function table(): string
{
global $wpdb;
return $wpdb->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
));
}
}
@@ -0,0 +1,244 @@
<?php
namespace Bookstore\Core\Order;
use Bookstore\Core\Payments\FraudCheck;
use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit;
/**
* Appendix B: "Supplier stock, price, and margin are rechecked live,
* synchronously, immediately before the customer's card is charged."
*
* Two checkout implementations, two different hook sets — confirmed by
* reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
* directly, not assumed:
*
* - Classic [woocommerce_checkout] shortcode: woocommerce_after_checkout_
* validation fires before any order row exists (a failure there blocks
* checkout with zero DB residue); woocommerce_checkout_order_processed
* fires after the order exists but before process_payment().
* - WooCommerce Blocks / Store API (what this site's storefront actually
* uses — verified live, the shop only serves Cart/Checkout blocks, no
* shortcode checkout page exists): a draft order already exists by the
* time the customer reaches checkout, so there's no "before any order
* row exists" phase at all. The one relevant hook is
* woocommerce_checkout_validate_order_before_payment ($order,
* \WP_Error $validation_errors), fired immediately before
* process_payment() — adding an error there throws a RouteException
* that aborts checkout before any charge is attempted.
*
* Both are wired below so the recheck runs regardless of which checkout
* implementation is actually rendering the page; run_checks() holds the
* one real implementation shared by both.
*/
class CheckoutRecheck
{
/**
* Design doc §05: "A small price cushion is acceptable, but stale
* supplier cost must never be trusted as final." A judgment call on
* the actual cushion amount — not specified in the plan — chosen as a
* flat $0.50 rather than a percentage so a cheap paperback and an
* expensive hardcover aren't held to wildly different tolerances.
*/
private const PRICE_CUSHION = 0.50;
public static function register(): void
{
add_action('woocommerce_after_checkout_validation', [self::class, 'validate_cart'], 10, 2);
add_action('woocommerce_checkout_order_processed', [self::class, 'finalize_order'], 10, 3);
add_action('woocommerce_checkout_validate_order_before_payment', [self::class, 'validate_order_before_payment'], 10, 2);
}
/** Classic checkout only — cart-level pass, before any order exists. */
public static function validate_cart(array $data, \WP_Error $errors): void
{
if (!WC()->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()));
}
}
@@ -0,0 +1,106 @@
<?php
namespace Bookstore\Core\Order;
defined('ABSPATH') || exit;
/**
* Design doc §04/Appendix B state machine: Pending -> 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 . ' <span class="count">(%s)</span>';
return [
0 => $markup,
1 => $markup,
'singular' => $markup,
'plural' => $markup,
'context' => null,
'domain' => null,
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace Bookstore\Core\Order;
use Bookstore\Core\Catalog\Isbn;
use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit;
/**
* Appendix B: supplier price/stock/margin rechecked live, synchronously,
* immediately before charging. Sibling to PricingEngine::price_for_work()
* that also surfaces which offer won — price_for_work() only ever returns
* a float, but the checkout recheck needs to pin the exact offer_id (for
* Order\AuditTrail and, eventually, Week 4's supplier-submission
* idempotency) rather than re-resolving best_offer_for_isbns() a third
* time and risking a different answer if the clock/stock moved between
* calls.
*/
class PriceRecheck
{
/**
* Null if the work no longer has any sellable offer at all — the
* caller treats that identically to "margin recheck failed": cancel
* before payment is taken.
*/
public static function recheck_for_work(int $work_id): ?object
{
$isbns = Isbn::isbns_for_work($work_id);
$offer = SupplierOffer::best_offer_for_isbns($isbns);
if (!$offer) {
return null;
}
return (object) [
'offer' => $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;
}
}
@@ -0,0 +1,127 @@
<?php
namespace Bookstore\Core\Order;
use Bookstore\Core\Catalog\SupplierOffer;
defined('ABSPATH') || exit;
/**
* Design doc §04/§06: "Supplier Ordered" — submitting the charged order to
* the actual supplier. Routed through Action Scheduler now, even against
* the synthetic gutenberg_test supplier which has no real API to be
* slow/flaky against, so Week 4's BooksRun/Ingram integration only swaps
* this class's run() body for a real HTTP call — the scheduling, locking,
* status transition, and exception-routing plumbing is already correct
* and untouched. Also where the plan doc's "BooksRun/API timeout -> 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.'
);
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* Design doc §05: "local WooCommerce fraud checks plus Helcim's
* payment-side risk signals." Deliberately a no-op by default for Week 3
* — the user has decided not to build custom fraud heuristics now, only
* the extension point an optional paid plugin (Shop Defender) plugs into
* later via add_filter('bsc_fraud_check_result', ...). The step still
* runs and is logged on every order, unconditionally, so "Fraud Check" is
* a real, auditable stage in the state machine rather than an absent one
* — see Order\CheckoutRecheck::finalize_order(), which calls this right
* before an order is allowed to proceed to the payment gateway.
*/
class FraudCheck
{
/** @return array{pass: bool, reason: string} */
public static function run(\WC_Order $order): array
{
$result = apply_filters('bsc_fraud_check_result', [
'pass' => true,
'reason' => 'no-op: fraud plugin not installed',
], $order);
$order->add_order_note(
'Fraud check: ' . ($result['pass'] ? 'passed' : 'FAILED') . ' — ' . $result['reason']
);
return $result;
}
}
@@ -0,0 +1,79 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* Seeds the Helcim gateway's WooCommerce settings from the environment.
* Gateway id `helcimjs`, settings option `woocommerce_helcimjs_settings`,
* field keys `enabled`/`title`/`description`/`environment`/
* `sandbox_api_token`/`live_api_token`/`enable_saved_cards` — confirmed by
* installing helcim-commerce-for-woocommerce v5.0.3 in dev and reading its
* includes/Gateway.php + includes/Options.php directly, not guessed. No
* separate "account ID" field exists on this plugin — HELCIM_ACCOUNT_ID
* (still scaffolded in .env/docker-compose.yml) is unused here.
*
* _FILE env-var reading mirrors Integration\HardcoverAdapter::token()
* exactly — same reasoning: a plain ${VAR} is visible via `docker
* inspect`/`/proc/<pid>/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);
}
}
@@ -56,4 +56,18 @@ class PricingEngine
} }
return self::calculate_retail_price((float) $offer->base_price, (float) $offer->supplier_ship); 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);
}
} }
@@ -91,6 +91,33 @@ class Schema
PRIMARY KEY (user_id) PRIMARY KEY (user_id)
) {$charset_collate};"; ) {$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) { foreach ($sql as $statement) {
dbDelta($statement); dbDelta($statement);
} }
@@ -0,0 +1,40 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
do_action('woocommerce_email_header', $email_heading, $email);
?>
<p>
<?php
printf(
'Order #%s has been routed to the exception queue and needs manual review before it can proceed.',
esc_html($order->get_order_number())
);
?>
</p>
<p><strong>Most recent order note (reason):</strong></p>
<blockquote>
<?php
$notes = wc_get_order_notes(['order_id' => $order->get_id(), 'limit' => 1]);
echo $notes ? wp_kses_post($notes[0]->content) : 'No note found.';
?>
</blockquote>
<?php
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_kses_post(wpautop(wptexturize($additional_content)));
}
do_action('woocommerce_email_footer', $email);
@@ -0,0 +1,32 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
echo wp_strip_all_tags($email_heading) . "\n\n";
printf(
"Order #%s has been routed to the exception queue and needs manual review before it can proceed.\n\n",
$order->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";
}
@@ -0,0 +1,28 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
do_action('woocommerce_email_header', $email_heading, $email);
?>
<p>
<?php printf('Good news — order #%s has shipped!', esc_html($order->get_order_number())); ?>
</p>
<?php
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_kses_post(wpautop(wptexturize($additional_content)));
}
do_action('woocommerce_email_footer', $email);
@@ -0,0 +1,25 @@
<?php
/**
* @var WC_Order $order
* @var string $email_heading
* @var string $additional_content
* @var bool $sent_to_admin
* @var bool $plain_text
* @var WC_Email $email
*/
defined('ABSPATH') || exit;
echo wp_strip_all_tags($email_heading) . "\n\n";
printf("Good news — order #%s has shipped!\n\n", $order->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";
}