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:
1 parent
73c64d94c1
commit
4079bfceac
20 files changed
+1224
-15
No files matched your search
@@ -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.<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
|
||||
|
||||
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 <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
|
||||
|
||||
- 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
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
|
||||
+8
-6
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
* <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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
) {$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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user