From 197938f482dea3611821e1c532c7e8018f559996 Mon Sep 17 00:00:00 2001 From: Twooey Date: Fri, 28 Aug 2026 15:17:46 -0400 Subject: [PATCH] pushing stuff that was worked on --- README.md | 67 ++++--- deploy/deploy.sh | 23 ++- ...okstore_prelaunch_implementation_plan.docx | Bin 52393 -> 53715 bytes .../assets/js/helcim-checkout-block.js | 182 ++++++++++++++++++ .../plugins/bookstore-core/bookstore-core.php | 26 ++- .../includes/Membership/Tier.php | 2 +- .../includes/Order/AuditTrail.php | 28 ++- .../includes/Order/CheckoutRecheck.php | 67 +++++-- .../includes/Order/OrderStatuses.php | 42 ++-- .../includes/Order/SupplierOrderJob.php | 106 ++++++---- .../includes/Payments/CheckoutController.php | 152 +++++++++++++++ .../includes/Payments/CheckoutSession.php | 51 +++++ .../includes/Payments/FraudCheck.php | 9 +- .../includes/Payments/HelcimApiException.php | 9 + .../Payments/HelcimBlocksIntegration.php | 64 ++++++ .../includes/Payments/HelcimClient.php | 123 ++++++++++++ .../includes/Payments/HelcimGateway.php | 127 ++++++++++++ ...lcimConfig.php => HelcimGatewayConfig.php} | 38 ++-- .../plugins/bookstore-core/patchwork.json | 3 + .../tests/Unit/Payments/HelcimClientTest.php | 115 +++++++++++ .../tests/Unit/Payments/HelcimGatewayTest.php | 84 ++++++++ .../bookstore-core/tests/bootstrap.php | 57 ++++++ 22 files changed, 1251 insertions(+), 124 deletions(-) create mode 100644 wp-content/plugins/bookstore-core/assets/js/helcim-checkout-block.js create mode 100644 wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php create mode 100644 wp-content/plugins/bookstore-core/includes/Payments/CheckoutSession.php create mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php create mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimBlocksIntegration.php create mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimClient.php create mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimGateway.php rename wp-content/plugins/bookstore-core/includes/Payments/{HelcimConfig.php => HelcimGatewayConfig.php} (59%) create mode 100644 wp-content/plugins/bookstore-core/patchwork.json create mode 100644 wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php create mode 100644 wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimGatewayTest.php diff --git a/README.md b/README.md index ae7d8f3..42edd38 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,18 @@ 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 +### Payments: Helcim (preauth/capture) -`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 Helcim gateway is a **custom `WC_Payment_Gateway`** shipped inside +`bookstore-core` (`Payments\HelcimGateway`, built directly against +HelcimPay.js) — not the off-the-shelf `helcim-commerce-for-woocommerce` +plugin, which `deploy.sh` deliberately does not install (it only +supports immediate charges; see design doc Appendix B). Checkout +authorizes (holds) the card; the charge only actually happens once the +supplier order succeeds (`Order\SupplierOrderJob`) — see that class and +`Payments\HelcimClient`'s docblocks for the full sequence. -The plugin needs a Helcim **sandbox API token**, which isn't something a +The gateway 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. @@ -118,13 +123,17 @@ public API can hand out, so it's a one-time manual step per environment: `deploy.sh` writes the token to a gitignored secrets file the same way it already does for `HARDCOVER_API_TOKEN` (see `secrets//` above), and -`Payments\HelcimConfig::ensure_configured_from_env()` seeds the gateway's -WooCommerce settings (sandbox mode, disabled by default — a human still -enables it at checkout once ready) the first time it finds a token. Until a -token exists, the plugin is installed and inert; nothing else in this repo -is blocked on it. Go-live (switching the gateway from sandbox to Helcim's -live environment) is a deliberate manual step on the settings screen, not -something this repo automates. +`Payments\HelcimGatewayConfig::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, `Payments\HelcimClient` runs in fake +mode** — every preauth/capture/reverse call returns a synthetic approved +response instead of hitting the real API, so the whole checkout -> +preauth -> fraud review -> supplier order -> capture/reverse flow is +testable end-to-end without one (same pattern as the synthetic +`gutenberg_test` supplier). Go-live (switching from sandbox to Helcim's +live environment) is a deliberate manual step on the settings screen, +not something this repo automates. ### Admin access restriction (production) @@ -201,28 +210,30 @@ with a bigger `--limit` continues forward instead of rescanning. Pricing `includes/Pricing/PricingEngine.php` and `includes/Shipping/BSC_Free_Shipping.php`. -## Checkout + order engine (Week 3) +## Checkout + order engine (Week 3, payment flow revised) -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. +A synchronous pre-authorization supplier/margin recheck runs right +before the card is held (`Order\CheckoutRecheck`, price/margin only — +never charges anything). The custom Helcim gateway (see "Payments: +Helcim" above) then holds the card and, once the fraud check +(`Payments\FraudCheck`) passes, moves the order to `bsc-authorized` +(held, not charged). `Order\SupplierOrderJob` picks it up from there: +submits to the supplier, and only on success **captures** the hold (the +card is actually charged for the first time here) before moving to +`bsc-ordered` → `bsc-shipped` → `completed`. Any failure at any stage — +no sellable offer, margin no longer holds, fraud check fails, or the +supplier submission itself fails — lands the order in `bsc-exception` +("Needs Review"), which is the review queue: just a filterable +WooCommerce order status, not a separate table. Because nothing is +charged until capture, most failures are a **void** (`AuditTrail::record_void()`, +zero cost) rather than a refund — a refund only happens for the rarer +case of something going wrong *after* a real capture. `wp bookstore force-offer-status ` (see `wp bookstore help`) forces a specific ISBN's synthetic offer into a specific state — `SyntheticOfferGenerator`'s rolls are random, so this is how to reliably exercise the recheck's failure paths (price-increase, -out-of-stock, discontinued-after-charge) in a real checkout. +out-of-stock, discontinued-after-supplier-submission) in a real checkout. Real BooksRun/Ingram supplier adapters are still Week 4 — `SupplierOrderJob` runs against the synthetic `gutenberg_test` supplier for now, deliberately diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 53f0a6d..e7c45e1 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -68,8 +68,9 @@ printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password" 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. +# Payments\HelcimGatewayConfig::is_configured() is false (and the +# gateway falls back to fake mode — see HelcimClient) 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 @@ -117,11 +118,27 @@ if ! $WP core is-installed; then --skip-email fi +# WordPress defaults to plain "?p=123" permalinks on a fresh install, +# which silently breaks routing for /my-account/, the Store API +# (/wp-json/wc/store/v1/...), and any other pretty-URL path — confirmed +# directly (my-account fell through to the homepage instead of 404ing, +# which made the problem non-obvious). "Rewrite rules are empty" further +# down in this script was this same root cause going unfixed on every +# deploy until now. +echo "==> setting pretty permalinks (required for Store API / my-account routing)" +$WP rewrite structure '/%postname%/' +$WP rewrite flush --hard + echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)" $WP theme install blocksy --activate echo "==> installing/activating required plugins" -$WP plugin install woocommerce redis-cache blocksy-companion helcim-commerce-for-woocommerce --activate +# helcim-commerce-for-woocommerce (the off-the-shelf, immediate-charge- +# only plugin) is deliberately NOT installed here anymore — Payments\ +# HelcimGateway (shipped inside bookstore-core) replaces it with a +# preauth-then-capture flow built directly against HelcimPay.js. See +# design doc Appendix B's revised decision. +$WP plugin install woocommerce redis-cache blocksy-companion --activate $WP plugin activate bookstore-core $WP redis enable || true diff --git a/docs/online_bookstore_prelaunch_implementation_plan.docx b/docs/online_bookstore_prelaunch_implementation_plan.docx index 864b2a4ee947486ea48c775a17fed2172883ed10..968385624d808fb953270ffae3f042ddc3c2005f 100644 GIT binary patch delta 16892 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 II0NDV0LzP7RsaA1 delta 15677 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 diff --git a/wp-content/plugins/bookstore-core/assets/js/helcim-checkout-block.js b/wp-content/plugins/bookstore-core/assets/js/helcim-checkout-block.js new file mode 100644 index 0000000..565d2c6 --- /dev/null +++ b/wp-content/plugins/bookstore-core/assets/js/helcim-checkout-block.js @@ -0,0 +1,182 @@ +/** + * Blocks/Store API payment method for Payments\HelcimGateway (id + * bsc_helcim). Registered via wc.wcBlocksRegistry.registerPaymentMethod + * since this store only serves Cart/Checkout blocks — see + * Payments\HelcimBlocksIntegration's docblock. + * + * Flow: customer clicks "Enter card details" -> POST /init (server talks + * to Helcim, returns checkoutToken+secretToken) -> load HelcimPay.js, + * render its iframe -> customer completes card entry -> HelcimPay.js + * postMessages the result to window -> POST /confirm (server validates + * the hash and stashes the preauth server-side, keyed to this session — + * see Payments\CheckoutSession) -> only then does onPaymentSetup allow + * WooCommerce to actually submit the order, which is what triggers + * HelcimGateway::process_payment() to pick up that stashed preauth. + * + * Fake mode (no real Helcim token configured yet — see HelcimClient's + * docblock): skips loading the real HelcimPay.js iframe entirely in + * favor of a "Simulate card entry" button that drives the exact same + * /init + /confirm calls, so this whole flow is testable today. + */ +(function () { + var registerPaymentMethod = window.wc.wcBlocksRegistry.registerPaymentMethod; + var el = window.wp.element.createElement; + var useState = window.wp.element.useState; + var useEffect = window.wp.element.useEffect; + var settings = window.bscHelcimData || {}; + var label = settings.title || 'Credit Card'; + + function restPost(path, body) { + return fetch(settings.restUrl + path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': settings.nonce, + }, + body: JSON.stringify(body || {}), + credentials: 'same-origin', + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) { + throw new Error(data.error || 'Payment request failed.'); + } + return data; + }); + }); + } + + function loadHelcimPayScript() { + return new Promise(function (resolve, reject) { + if (window.appendHelcimPayIframe) { + resolve(); + return; + } + var script = document.createElement('script'); + script.src = 'https://secure.helcim.app/helcim-pay/services/start.js'; + script.onload = resolve; + script.onerror = function () { + reject(new Error('Could not load HelcimPay.js.')); + }; + document.body.appendChild(script); + }); + } + + /** Exact event shape per Helcim's own documented example: event.data = {eventName, eventStatus, eventMessage}. */ + function waitForHelcimResult(checkoutToken) { + return new Promise(function (resolve, reject) { + var identifierKey = 'helcim-pay-js-' + checkoutToken; + function handleMessage(event) { + if (!event.data || event.data.eventName !== identifierKey) { + return; + } + if (event.data.eventStatus === 'SUCCESS') { + window.removeEventListener('message', handleMessage); + resolve(event.data.eventMessage); + } else if (event.data.eventStatus === 'ABORTED') { + window.removeEventListener('message', handleMessage); + reject(new Error('Card entry failed or was cancelled.')); + } + // 'HIDE' (modal closed without ABORTED/SUCCESS) is ignored here deliberately. + } + window.addEventListener('message', handleMessage); + }); + } + + function runRealFlow() { + var checkoutToken, secretToken; + return restPost('/init', {}) + .then(function (init) { + checkoutToken = init.checkoutToken; + secretToken = init.secretToken; + return loadHelcimPayScript(); + }) + .then(function () { + window.appendHelcimPayIframe(checkoutToken); + return waitForHelcimResult(checkoutToken); + }) + .then(function (eventMessage) { + return restPost('/confirm', { + rawDataResponse: eventMessage.data || eventMessage, + checkoutToken: checkoutToken, + secretToken: secretToken, + }); + }); + } + + function runFakeFlow() { + return restPost('/init', {}).then(function () { + return restPost('/confirm', {}); + }); + } + + function Content(props) { + var stateHook = useState('idle'); // idle | working | ready | error + var status = stateHook[0]; + var setStatus = stateHook[1]; + var errorHook = useState(''); + var error = errorHook[0]; + var setError = errorHook[1]; + + useEffect( + function () { + var unsubscribe = props.eventRegistration.onPaymentSetup(function () { + if (status !== 'ready') { + return { + type: props.emitResponse.responseTypes.ERROR, + message: 'Please complete card entry before placing your order.', + }; + } + return { type: props.emitResponse.responseTypes.SUCCESS }; + }); + return unsubscribe; + }, + [status, props.eventRegistration, props.emitResponse] + ); + + function handleClick() { + setStatus('working'); + setError(''); + var flow = settings.fakeMode ? runFakeFlow() : runRealFlow(); + flow + .then(function () { + setStatus('ready'); + }) + .catch(function (e) { + setStatus('error'); + setError(e.message); + }); + } + + return el( + 'div', + { className: 'bsc-helcim-payment-method' }, + settings.description ? el('p', {}, settings.description) : null, + status === 'ready' + ? el('p', {}, '✓ Card authorized — ready to place order.') + : el( + 'button', + { type: 'button', onClick: handleClick, disabled: status === 'working' }, + status === 'working' + ? 'Verifying card…' + : settings.fakeMode + ? 'Simulate card entry (dev/fake mode)' + : 'Enter card details' + ), + error ? el('p', { style: { color: 'red' } }, error) : null + ); + } + + registerPaymentMethod({ + name: 'bsc_helcim', + label: label, + content: el(Content), + edit: el(Content), + canMakePayment: function () { + return true; + }, + ariaLabel: label, + supports: { + features: ['products'], + }, + }); +})(); diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index e87bcf7..c9af1c2 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -51,7 +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(); + \Bookstore\Core\Payments\HelcimGatewayConfig::ensure_configured_from_env(); }); add_filter('woocommerce_shipping_methods', function (array $methods) { @@ -59,6 +59,22 @@ add_filter('woocommerce_shipping_methods', function (array $methods) { return $methods; }); +// Preauth-then-capture payment flow against HelcimPay.js directly — +// design doc Appendix B's revised decision (see includes/Payments/). +// Replaces the off-the-shelf helcim-commerce-for-woocommerce plugin's +// immediate-charge gateway, not just adds to it — see deploy.sh, which +// no longer installs that plugin. +add_filter('woocommerce_payment_gateways', function (array $gateways) { + $gateways[] = \Bookstore\Core\Payments\HelcimGateway::class; + return $gateways; +}); + +add_action('woocommerce_blocks_payment_method_type_registration', function ($registry) { + $registry->register(new \Bookstore\Core\Payments\HelcimBlocksIntegration()); +}); + +\Bookstore\Core\Payments\CheckoutController::register(); + // Membership tiers/benefits: design doc §07 extension (see includes/Membership/). \Bookstore\Core\Membership\MemberDiscount::register(); \Bookstore\Core\Membership\Registration::register(); @@ -90,10 +106,10 @@ add_filter('woocommerce_email_classes', function (array $emails) { 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. +// Recorded against the order-audit ledger for every refund — now mostly +// just admin-initiated manual refunds after a real capture, since +// SupplierOrderJob's automatic pre-capture failure path is a void +// (AuditTrail::record_void(), zero cost) rather than a refund. add_action('woocommerce_order_refunded', function (int $order_id, int $refund_id) { \Bookstore\Core\Order\AuditTrail::record_refund($order_id); }, 10, 2); diff --git a/wp-content/plugins/bookstore-core/includes/Membership/Tier.php b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php index f0afff4..e777738 100644 --- a/wp-content/plugins/bookstore-core/includes/Membership/Tier.php +++ b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php @@ -198,7 +198,7 @@ class Tier * Seeds guest/free_member with today's real defaults — only if * missing by slug, so a human's later edits via the CLI/settings * screen are never clobbered on a later plugins_loaded (same - * never-overwrite convention as Payments\HelcimConfig). + * never-overwrite convention as Payments\HelcimGatewayConfig). */ public static function seed_defaults(): void { diff --git a/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php b/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php index b39e84c..b78acc8 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php +++ b/wp-content/plugins/bookstore-core/includes/Order/AuditTrail.php @@ -6,8 +6,11 @@ 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. + * (CheckoutRecheck::run_checks()) and updated once — either on refund + * (record_refund(), a post-capture reversal) or on void (record_void(), + * a pre-capture supplier-order failure — the far more common case under + * the preauth-then-capture flow). Style mirrors Catalog\SupplierOffer + * exactly. */ class AuditTrail { @@ -63,6 +66,9 @@ class AuditTrail * 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. + * Only reachable now for a refund *after* a real capture (e.g. a + * manual admin refund post-fulfillment) — a failed supplier order + * before capture goes through record_void() instead, below. */ public static function record_refund(int $order_id): void { @@ -75,4 +81,22 @@ class AuditTrail $order_id )); } + + /** + * Preauth-then-capture rework: a failed supplier order before capture + * reverses the hold (Order\SupplierOrderJob::fail_and_release()) + * rather than refunding a charge that was never made — genuinely + * zero cost, not the processing-fee loss record_refund() models. + */ + public static function record_void(int $order_id): void + { + global $wpdb; + $wpdb->query($wpdb->prepare( + "UPDATE " . self::table() . " + SET processor_cost_actual = 0, + actual_contribution = 0 + WHERE order_id = %d", + $order_id + )); + } } diff --git a/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php b/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php index af046e6..b1c7162 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php +++ b/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php @@ -2,14 +2,25 @@ namespace Bookstore\Core\Order; -use Bookstore\Core\Payments\FraudCheck; +use Bookstore\Core\Payments\CheckoutSession; +use Bookstore\Core\Payments\HelcimApiException; +use Bookstore\Core\Payments\HelcimClient; +use Bookstore\Core\Payments\HelcimGateway; 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." + * Appendix B (revised for the preauth-then-capture rework): "Supplier + * stock, price, and margin are rechecked live, synchronously, + * immediately before the customer's card is authorized (held)." The + * fraud check moved out of run_checks() and into + * Payments\HelcimGateway::process_payment() — it now runs *after* the + * preauth exists rather than before, per the new checkout -> preauth -> + * fraud review -> supplier order -> capture sequence. This class still + * owns price/margin only, and (new) reversing any preauth that's + * already been taken if that recheck fails here — see the "wrinkle" + * note on validate_order_before_payment() below. * * Two checkout implementations, two different hook sets — confirmed by * reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php @@ -132,6 +143,13 @@ class CheckoutRecheck * *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. + * + * New wrinkle from the preauth rework: by the time this hook fires, + * the customer has already completed a preauth via the Blocks + * checkout JS (Payments\HelcimGateway isn't invoked yet — that only + * happens after this hook passes). If the recheck fails here, that + * hold is real and live but process_payment() will never run to + * clean it up — so run_checks()'s failure path reverses it directly. */ public static function validate_order_before_payment(\WC_Order $order, \WP_Error $validation_errors): void { @@ -155,12 +173,12 @@ class CheckoutRecheck /** * 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. + * (audit rows already written) or a customer-facing failure message + * (order already routed to the exception status, any preauth already + * reversed) on failure. Audit rows are only ever inserted after every + * item has 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 { @@ -177,13 +195,14 @@ class CheckoutRecheck $quoted_unit = (float) $item->get_total() / $quantity; if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) { + self::reverse_pending_preauth(); 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.'; + return 'Sorry, part of your order is no longer available at its listed price. Any card hold has been released — please try again.'; } $offer = $recheck->offer; @@ -206,12 +225,6 @@ class CheckoutRecheck ]; } - $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); } @@ -219,6 +232,28 @@ class CheckoutRecheck return null; } + /** + * Fraud check already ran (Payments\HelcimGateway::process_payment(), + * after the preauth) by the time price/margin ever gets a chance to + * fail here — this only fires when the preauth exists but the recheck + * itself fails first. Best-effort: nothing was ever charged either + * way, and an unreversed hold just expires on its own within Helcim's + * 7-day window if this call fails. + */ + private static function reverse_pending_preauth(): void + { + $pending = CheckoutSession::pending_preauth(); + if (!$pending) { + return; + } + CheckoutSession::clear_pending_preauth(); + try { + HelcimClient::reverse($pending['preauth_transaction_id'], HelcimGateway::client_ip()); + } catch (HelcimApiException $e) { + error_log('bsc_helcim: reverse failed after a failed price/margin recheck: ' . $e->getMessage()); + } + } + private static function route_to_exception(\WC_Order $order, string $reason): void { $order->update_status(OrderStatuses::EXCEPTION, $reason); diff --git a/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php b/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php index 65a9cc0..eb343ea 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php +++ b/wp-content/plugins/bookstore-core/includes/Order/OrderStatuses.php @@ -5,18 +5,19 @@ 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). + * Design doc §04/Appendix B state machine, revised again for the + * preauth-then-capture rework: Pending -> Margin Verify -> Authorized + * (Held) -> Fraud Check -> Supplier Ordered -> Charged -> Shipped -> + * Complete. Pending/Charged/Complete map onto WooCommerce's own + * pending/processing/completed. Margin Verify still completes within one + * request (CheckoutRecheck) and is logged as an order note rather than a + * full status transition. Authorized IS a real persisted status now — + * unlike the single-request sub-steps, it can legitimately sit for a + * while (fraud review, the supplier-order Action Scheduler job) with + * real money on hold, so it needs to be visible/filterable in wp-admin, + * not just logged. Same reasoning already covered bsc-exception + * ("every failure path must end in a visible review state") and the two + * forward-progress steps (bsc-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 @@ -33,6 +34,11 @@ class OrderStatuses // the nonsensical 'bsc-supplier-orde'. 'wc-bsc-ordered' is 14 chars. public const SUPPLIER_ORDERED = 'bsc-ordered'; public const SHIPPED = 'bsc-shipped'; + // Reintroduced by the preauth-then-capture rework (design doc + // Appendix B, revised again) — sits between Pending and (native) + // Processing: the card is held, not yet charged. 'wc-bsc-authorized' + // is 17 chars, fits the same VARCHAR(20) constraint above. + public const AUTHORIZED = 'bsc-authorized'; public static function register(): void { @@ -42,6 +48,15 @@ class OrderStatuses public static function register_post_statuses(): void { + register_post_status('wc-' . self::AUTHORIZED, [ + 'label' => 'Authorized (Held)', + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + 'label_count' => self::noop_label_count('Authorized (Held)'), + ]); + register_post_status('wc-' . self::EXCEPTION, [ 'label' => 'Needs Review', 'public' => false, @@ -75,6 +90,9 @@ class OrderStatuses $with_new = []; foreach ($statuses as $key => $label) { $with_new[$key] = $label; + if ($key === 'wc-pending') { + $with_new['wc-' . self::AUTHORIZED] = 'Authorized (Held)'; + } if ($key === 'wc-processing') { $with_new['wc-' . self::SUPPLIER_ORDERED] = 'Supplier Ordered'; $with_new['wc-' . self::SHIPPED] = 'Shipped'; diff --git a/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php b/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php index 47776d3..acbab78 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php +++ b/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php @@ -3,33 +3,47 @@ namespace Bookstore\Core\Order; use Bookstore\Core\Catalog\SupplierOffer; +use Bookstore\Core\Payments\HelcimApiException; +use Bookstore\Core\Payments\HelcimClient; +use Bookstore\Core\Payments\HelcimGateway; 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 + * Design doc §04/§06: "Supplier Ordered" — submitting the 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. + * + * Preauth-then-capture rework: this class now runs BEFORE the card is + * actually charged, not after. It triggers on the order reaching + * Order\OrderStatuses::AUTHORIZED (preauth held, fraud check already + * passed — Payments\HelcimGateway) rather than woocommerce_payment_complete. + * A successful supplier submission captures the held funds for the first + * time (payment_complete() only fires here now, at capture); a failed one + * reverses the hold instead of refunding a charge that was never made — + * the whole point of this rework, replacing Appendix B's accepted + * "refund, lose the processing fee" risk with a genuinely zero-cost void. */ class SupplierOrderJob { public static function register(): void { - add_action('woocommerce_payment_complete', [self::class, 'schedule_for_order']); + add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [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. + * WooCommerce's per-status transition hooks can fire more than once + * for the same order (e.g. a status re-saved without actually + * changing) — 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 { @@ -72,7 +86,7 @@ class SupplierOrderJob if (!$offer) { $order->update_meta_data('_bsc_supplier_order_status', 'failed'); $order->save(); - self::fail_and_refund( + self::fail_and_release( $order, "Pinned supplier offer #{$audit_row->offer_id} is no longer sellable at fulfillment time." ); @@ -83,44 +97,66 @@ class SupplierOrderJob $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); + + self::capture_and_complete($order); } 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. + * The supplier order just succeeded — this is the first point the + * card is actually charged. Captures the preauth held since checkout, + * then calls WC's own payment_complete() (which now fires + * woocommerce_payment_complete for real, restoring that hook's + * original meaning) before moving to Supplier Ordered. * - * 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. + * Capture failing here (card expired/declined during the hold window + * — rare, but real) is its own distinct failure mode from a supplier- + * side problem: the supplier submission already (synthetically, for + * now) succeeded, so this can't cleanly fall back to fail_and_release() + * the way an unsellable offer can. Routes to exception with an + * explicit "needs manual follow-up" note instead — the same manual- + * fallback philosophy Appendix B already accepted for one-off + * unfulfillable orders. */ - private static function fail_and_refund(\WC_Order $order, string $reason): void + private static function capture_and_complete(\WC_Order $order): void + { + $preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id'); + $amount = (float) $order->get_total(); + + try { + $capture = HelcimClient::capture($preauth_id, $amount, HelcimGateway::client_ip()); + } catch (HelcimApiException $e) { + $order->update_status( + OrderStatuses::EXCEPTION, + "Supplier order succeeded but capture failed ({$e->getMessage()}) — needs manual follow-up, do not resubmit to the supplier." + ); + return; + } + + $order->payment_complete((string) ($capture['transactionId'] ?? $preauth_id)); + $order->update_status(OrderStatuses::SUPPLIER_ORDERED); + } + + /** + * Appendix B's residual risk ("a charge that later needs to be + * undone becomes a refund... the store loses only the processing + * fee") is exactly what this rework was for: nothing has been + * charged yet at this point, so a failed supplier order releases the + * hold instead — genuinely zero cost, not "only the processing fee." + */ + private static function fail_and_release(\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)) { + $preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id'); + try { + HelcimClient::reverse($preauth_id, HelcimGateway::client_ip()); + AuditTrail::record_void($order->get_id()); + } catch (HelcimApiException $e) { $order->add_order_note( - 'Automatic refund could not be issued (' . $refund->get_error_message() . ') — needs a manual refund.' + 'Could not release the card hold (' . $e->getMessage() . ') — it will expire on its own within 7 days; no manual refund needed.' ); } } diff --git a/wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php b/wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php new file mode 100644 index 0000000..0b8187b --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php @@ -0,0 +1,152 @@ + 'POST', + 'callback' => [self::class, 'init'], + 'permission_callback' => '__return_true', + ]); + + register_rest_route('bsc/v1', '/helcim/confirm', [ + 'methods' => 'POST', + 'callback' => [self::class, 'confirm'], + 'permission_callback' => '__return_true', + ]); + } + + /** + * Confirmed directly (live testing): a plain register_rest_route() + * callback does NOT get WC()->cart/WC()->session populated from the + * customer's session cookie the way WooCommerce's own Store API + * routes do (their cart endpoint saw the item; this one reported + * "Cart is empty" for the identical session/cookie). wc_load_cart() + * is WooCommerce's own documented fix for exactly this — background/ + * REST/AJAX contexts outside the normal front-end bootstrap that + * still need the real session-backed cart. + */ + private static function ensure_cart_loaded(): void + { + if (function_exists('wc_load_cart')) { + wc_load_cart(); + } + // Confirmed directly (live testing): a freshly wc_load_cart()'d + // cart's ->get_total('edit') reads 0 until something triggers its + // lazy total calculation — is_empty()/get_cart() happen to do + // this as a side effect, which is why init() (checks is_empty() + // first) got the right amount but confirm() (reads the total as + // its first cart touch) silently got 0. Calculating explicitly + // here removes the dependency on that incidental ordering. + if (WC()->cart) { + WC()->cart->calculate_totals(); + } + } + + public static function init(\WP_REST_Request $request): \WP_REST_Response + { + self::ensure_cart_loaded(); + + if (!WC()->cart || WC()->cart->is_empty()) { + return new \WP_REST_Response(['error' => 'Cart is empty.'], 400); + } + + $amount = (float) WC()->cart->get_total('edit'); + + try { + $result = HelcimClient::initialize_checkout($amount, get_woocommerce_currency(), 'preauth'); + } catch (HelcimApiException $e) { + return new \WP_REST_Response(['error' => $e->getMessage()], 502); + } + + return new \WP_REST_Response([ + 'checkoutToken' => $result['checkoutToken'], + 'secretToken' => $result['secretToken'] ?? null, + 'amount' => $amount, + 'fakeMode' => !empty($result['fake']), + ]); + } + + public static function confirm(\WP_REST_Request $request): \WP_REST_Response + { + self::ensure_cart_loaded(); + $amount = (float) WC()->cart->get_total('edit'); + + if (!HelcimGatewayConfig::is_configured()) { + $transaction = [ + 'transactionId' => random_int(900000000, 999999999), + 'status' => 'APPROVED', + 'cardToken' => 'fake_card_token_' . wp_generate_uuid4(), + ]; + } else { + $raw = $request->get_param('rawDataResponse'); + $secret = (string) $request->get_param('secretToken'); + $transaction = is_array($raw) ? $raw : json_decode((string) $raw, true); + + if (!is_array($transaction) || !self::hash_is_valid($transaction, $secret)) { + return new \WP_REST_Response(['error' => 'Could not validate the payment result.'], 400); + } + if (empty($transaction['transactionId']) || ($transaction['status'] ?? '') !== 'APPROVED') { + return new \WP_REST_Response(['error' => 'Payment was not approved.'], 400); + } + } + + CheckoutSession::stash_pending_preauth([ + 'preauth_transaction_id' => (int) $transaction['transactionId'], + 'card_token' => $transaction['cardToken'] ?? null, + 'amount' => $amount, + ]); + + return new \WP_REST_Response(['ok' => true]); + } + + /** + * Helcim's documented algorithm: sha256(json_encode(transaction data) + * . secretToken), compared against the response's own hash field — + * see this class's docblock for the "not independently confirmed + * against a real transaction yet" caveat. + */ + private static function hash_is_valid(array $transaction, string $secret): bool + { + if (empty($transaction['hash']) || $secret === '') { + return false; + } + $data = $transaction; + unset($data['hash']); + $expected = hash('sha256', wp_json_encode($data) . $secret); + return hash_equals($expected, (string) $transaction['hash']); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/CheckoutSession.php b/wp-content/plugins/bookstore-core/includes/Payments/CheckoutSession.php new file mode 100644 index 0000000..066a69a --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/CheckoutSession.php @@ -0,0 +1,51 @@ +session ? (string) WC()->session->get_customer_id() : (string) session_id(); + } + + public static function transient_key(): string + { + return 'bsc_preauth_' . md5(self::identity()); + } + + /** @param array{preauth_transaction_id:int, card_token:?string, amount:float} $data */ + public static function stash_pending_preauth(array $data): void + { + set_transient(self::transient_key(), $data, self::TTL); + } + + /** @return array{preauth_transaction_id:int, card_token:?string, amount:float}|null */ + public static function pending_preauth(): ?array + { + $data = get_transient(self::transient_key()); + return is_array($data) ? $data : null; + } + + public static function clear_pending_preauth(): void + { + delete_transient(self::transient_key()); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php b/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php index ce0606c..a421803 100644 --- a/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php +++ b/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php @@ -11,9 +11,12 @@ defined('ABSPATH') || exit; * 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. + * a real, auditable stage in the state machine rather than an absent one. + * Called from HelcimGateway::process_payment(), right after the card is + * preauthorized (held) but before the order is allowed to proceed to + * Authorized status — moved here from Order\CheckoutRecheck by the + * preauth-then-capture rework: fraud review now runs after the hold + * exists, not before, per design doc Appendix B's revised sequence. */ class FraudCheck { diff --git a/wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php new file mode 100644 index 0000000..d682bcb --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php @@ -0,0 +1,9 @@ +settings = get_option(HelcimGatewayConfig::OPTION_NAME, []); + } + + public function is_active(): bool + { + return ($this->settings['enabled'] ?? 'no') === 'yes'; + } + + public function get_payment_method_script_handles(): array + { + $asset_path = BSC_PLUGIN_DIR . 'assets/js/helcim-checkout-block.js'; + + wp_register_script( + 'bsc-helcim-checkout-block', + plugins_url('assets/js/helcim-checkout-block.js', BSC_PLUGIN_FILE), + ['wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-i18n'], + file_exists($asset_path) ? (string) filemtime($asset_path) : BSC_VERSION, + true + ); + + wp_localize_script('bsc-helcim-checkout-block', 'bscHelcimData', [ + 'title' => $this->settings['title'] ?? 'Credit Card', + 'description' => $this->settings['description'] ?? '', + 'restUrl' => esc_url_raw(rest_url('bsc/v1/helcim')), + 'nonce' => wp_create_nonce('wp_rest'), + 'fakeMode' => !HelcimGatewayConfig::is_configured(), + ]); + + return ['bsc-helcim-checkout-block']; + } + + public function get_payment_method_data(): array + { + return [ + 'title' => $this->settings['title'] ?? 'Credit Card', + 'description' => $this->settings['description'] ?? '', + ]; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/HelcimClient.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimClient.php new file mode 100644 index 0000000..382c4e9 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/HelcimClient.php @@ -0,0 +1,123 @@ + {checkoutToken, secretToken} + * - POST /v2/payment/capture {preAuthTransactionId, amount, ipAddress} + * -> transaction object (incl. cardToken, transactionId) + * - POST /v2/payment/reverse {cardTransactionId, ipAddress} + * -> transaction object + * Capture/reverse's exact base path (the /v2 prefix specifically) was + * not shown alongside their parameter docs the way initialize's was — + * confirm against a real sandbox call before this is ever used in + * anything but fake mode. + * + * FAKE MODE: HelcimGatewayConfig::is_configured() is false until a real + * token exists (none does yet, pre-launch). Rather than blocking all + * development on getting Helcim sandbox access, every method here + * returns a synthetic approved response when unconfigured — same + * pattern this project already uses on the supplier side before real + * BooksRun access existed (Import\SyntheticOfferGenerator, the + * gutenberg_test supplier). This lets the entire checkout -> preauth -> + * fraud -> supplier -> capture/reverse flow be driven for real through + * the WooCommerce/Blocks plumbing in dev today. The instant a real token + * lands in HELCIM_API_TOKEN, every call here switches to hitting the + * real API — no code change required anywhere. + */ +class HelcimClient +{ + private const API_BASE = 'https://api.helcim.com/v2'; + + public static function initialize_checkout(float $amount, string $currency, string $payment_type): array + { + if (!HelcimGatewayConfig::is_configured()) { + return [ + 'checkoutToken' => 'fake_checkout_' . wp_generate_uuid4(), + 'secretToken' => 'fake_secret_' . wp_generate_uuid4(), + 'fake' => true, + ]; + } + + return self::post('/helcim-pay/initialize', [ + 'paymentType' => $payment_type, + 'amount' => $amount, + 'currency' => $currency, + ], with_idempotency_key: false); + } + + public static function capture(int $preauth_transaction_id, float $amount, string $ip_address): array + { + if (!HelcimGatewayConfig::is_configured()) { + return self::fake_transaction_response($amount); + } + + return self::post('/payment/capture', [ + 'preAuthTransactionId' => $preauth_transaction_id, + 'amount' => $amount, + 'ipAddress' => $ip_address, + ], with_idempotency_key: true); + } + + public static function reverse(int $transaction_id, string $ip_address): array + { + if (!HelcimGatewayConfig::is_configured()) { + return self::fake_transaction_response(0.0); + } + + return self::post('/payment/reverse', [ + 'cardTransactionId' => $transaction_id, + 'ipAddress' => $ip_address, + ], with_idempotency_key: true); + } + + private static function fake_transaction_response(float $amount): array + { + return [ + 'transactionId' => random_int(900000000, 999999999), + 'status' => 'APPROVED', + 'amount' => $amount, + 'cardToken' => 'fake_card_token_' . wp_generate_uuid4(), + 'fake' => true, + ]; + } + + private static function post(string $path, array $body, bool $with_idempotency_key): array + { + $token = HelcimGatewayConfig::token(); + $headers = ['api-token' => $token, 'Content-Type' => 'application/json']; + if ($with_idempotency_key) { + $headers['idempotency-key'] = wp_generate_uuid4(); + } + + $response = wp_remote_post(self::API_BASE . $path, [ + 'headers' => $headers, + 'body' => wp_json_encode($body), + 'timeout' => 30, + ]); + + if (is_wp_error($response)) { + throw new HelcimApiException('Helcim API request failed: ' . $response->get_error_message()); + } + + $code = wp_remote_retrieve_response_code($response); + $decoded = json_decode(wp_remote_retrieve_body($response), true); + + if ($code < 200 || $code >= 300 || !is_array($decoded) || !empty($decoded['errors'])) { + $message = is_array($decoded) && !empty($decoded['errors']) + ? implode('; ', (array) $decoded['errors']) + : "unexpected HTTP {$code}"; + throw new HelcimApiException("Helcim API error on {$path}: {$message}"); + } + + return $decoded; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/HelcimGateway.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimGateway.php new file mode 100644 index 0000000..a2e5670 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Payments/HelcimGateway.php @@ -0,0 +1,127 @@ +id = 'bsc_helcim'; + $this->method_title = 'Helcim (preauth/capture)'; + $this->method_description = 'Card is held (preauth) at checkout and only captured once the supplier order succeeds — see design doc Appendix B.'; + $this->has_fields = false; + $this->supports = ['products']; + + $this->init_form_fields(); + $this->init_settings(); + + $this->title = $this->get_option('title', 'Credit Card'); + $this->description = $this->get_option('description', ''); + $this->enabled = $this->get_option('enabled', 'no'); + + add_action('woocommerce_update_options_payment_gateways_' . $this->id, [$this, 'process_admin_options']); + } + + public function init_form_fields(): void + { + $this->form_fields = [ + 'enabled' => [ + 'title' => 'Enable/Disable', + 'type' => 'checkbox', + 'label' => 'Enable this payment method', + 'default' => 'no', + ], + 'title' => [ + 'title' => 'Title', + 'type' => 'text', + 'default' => 'Credit Card', + ], + 'description' => [ + 'title' => 'Description', + 'type' => 'textarea', + 'default' => '', + ], + 'environment' => [ + 'title' => 'Environment', + 'type' => 'select', + 'options' => ['sandbox' => 'Sandbox', 'live' => 'Live'], + 'default' => 'sandbox', + ], + ]; + } + + public function process_payment($order_id): array + { + $order = wc_get_order($order_id); + $pending = CheckoutSession::pending_preauth(); + + if (!$order || !$pending) { + wc_add_notice('Your payment session expired — please try again.', 'error'); + return ['result' => 'failure']; + } + + CheckoutSession::clear_pending_preauth(); + + $order->update_meta_data('_bsc_preauth_transaction_id', $pending['preauth_transaction_id']); + $order->update_meta_data('_bsc_preauth_card_token', $pending['card_token']); + $order->update_meta_data('_bsc_preauth_amount', $pending['amount']); + $order->save(); + + $fraud = FraudCheck::run($order); + if (!$fraud['pass']) { + $this->reverse_preauth($pending['preauth_transaction_id'], "Fraud check failed: {$fraud['reason']}"); + $order->update_status(OrderStatuses::EXCEPTION, "Fraud check failed: {$fraud['reason']}"); + wc_add_notice('Your order could not be processed and has been placed under review. Your card was not charged.', 'error'); + return ['result' => 'failure']; + } + + $order->update_status(OrderStatuses::AUTHORIZED, 'Card authorized (preauth held) — not yet charged.'); + + return [ + 'result' => 'success', + 'redirect' => $this->get_return_url($order), + ]; + } + + private function reverse_preauth(int $preauth_transaction_id, string $reason): void + { + try { + HelcimClient::reverse($preauth_transaction_id, self::client_ip()); + } catch (HelcimApiException $e) { + // Nothing was charged either way; a failed reverse call just + // means the hold expires on its own within Helcim's 7-day + // window instead of being released immediately — logged so + // it's not silently invisible, matching "every failure path + // must end in a visible review state." + error_log("bsc_helcim: reverse failed for transaction {$preauth_transaction_id} ({$reason}): " . $e->getMessage()); + } + } + + public static function client_ip(): string + { + if (class_exists(\WC_Geolocation::class)) { + return \WC_Geolocation::get_ip_address(); + } + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Payments/HelcimConfig.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimGatewayConfig.php similarity index 59% rename from wp-content/plugins/bookstore-core/includes/Payments/HelcimConfig.php rename to wp-content/plugins/bookstore-core/includes/Payments/HelcimGatewayConfig.php index 5ee78d5..cb22a54 100644 --- a/wp-content/plugins/bookstore-core/includes/Payments/HelcimConfig.php +++ b/wp-content/plugins/bookstore-core/includes/Payments/HelcimGatewayConfig.php @@ -5,30 +5,36 @@ 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. + * Replaces Payments\HelcimConfig (which seeded settings for the + * off-the-shelf helcim-commerce-for-woocommerce plugin — removed, see + * design doc Appendix B's revised decision: that plugin only supports + * immediate-charge "Purchase" transactions, not the preauth/capture flow + * this now uses, so a custom gateway (Payments\HelcimGateway) replaces + * it rather than extending it). Gateway id `bsc_helcim`, settings option + * `woocommerce_bsc_helcim_settings` — our own, not the old plugin's. * * _FILE env-var reading mirrors Integration\HardcoverAdapter::token() - * exactly — same reasoning: a plain ${VAR} is visible via `docker + * exactly, same reasoning: a plain ${VAR} is visible via `docker * inspect`/`/proc//environ`, a 644 file inside a 700 secrets/ dir * isn't. */ -class HelcimConfig +class HelcimGatewayConfig { - private const OPTION_NAME = 'woocommerce_helcimjs_settings'; + public const OPTION_NAME = 'woocommerce_bsc_helcim_settings'; + /** + * False today (no HELCIM_API_TOKEN in any .env yet) — this is + * exactly the condition Payments\HelcimClient checks to decide + * whether to talk to the real Helcim API or return fake-mode + * responses. Flips to true, with zero code changes anywhere else, + * the moment a real sandbox token is added. + */ public static function is_configured(): bool { return self::token() !== null; } - private static function token(): ?string + public static function token(): ?string { $file = getenv('HELCIM_API_TOKEN_FILE'); if (!$file || !is_readable($file)) { @@ -40,18 +46,13 @@ class HelcimConfig /** * 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 + * already changed by hand in wp-admin. 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; @@ -67,7 +68,6 @@ class HelcimConfig 'title' => 'Credit Card', 'description' => '', 'environment' => 'sandbox', - 'enable_saved_cards' => 'no', ]; if (empty($settings['sandbox_api_token'])) { diff --git a/wp-content/plugins/bookstore-core/patchwork.json b/wp-content/plugins/bookstore-core/patchwork.json new file mode 100644 index 0000000..d62a7b8 --- /dev/null +++ b/wp-content/plugins/bookstore-core/patchwork.json @@ -0,0 +1,3 @@ +{ + "redefinable-internals": ["getenv", "is_readable", "file_get_contents"] +} diff --git a/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php b/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php new file mode 100644 index 0000000..3fc7052 --- /dev/null +++ b/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php @@ -0,0 +1,115 @@ +justReturn(false); + Functions\when('wp_generate_uuid4')->justReturn('fake-uuid'); + Functions\expect('wp_remote_post')->never(); + + $init = HelcimClient::initialize_checkout(42.00, 'USD', 'preauth'); + $this->assertTrue($init['fake']); + $this->assertNotEmpty($init['checkoutToken']); + $this->assertNotEmpty($init['secretToken']); + + $capture = HelcimClient::capture(123456, 42.00, '127.0.0.1'); + $this->assertSame('APPROVED', $capture['status']); + $this->assertIsInt($capture['transactionId']); + $this->assertNotEmpty($capture['cardToken']); + + $reverse = HelcimClient::reverse(123456, '127.0.0.1'); + $this->assertSame('APPROVED', $reverse['status']); + } + + public function test_real_mode_initialize_sends_the_documented_request_shape(): void + { + Functions\when('getenv')->justReturn('/fake/path/to/token'); + Functions\when('is_readable')->justReturn(true); + Functions\when('file_get_contents')->justReturn('real-token-abc'); + Functions\when('wp_json_encode')->alias('json_encode'); + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_body')->justReturn(json_encode([ + 'checkoutToken' => 'real-checkout-token', + 'secretToken' => 'real-secret-token', + ])); + + Functions\expect('wp_remote_post') + ->once() + ->withArgs(function ($url, $args) { + $this->assertSame('https://api.helcim.com/v2/helcim-pay/initialize', $url); + $this->assertSame('real-token-abc', $args['headers']['api-token']); + $this->assertArrayNotHasKey('idempotency-key', $args['headers']); + $body = json_decode($args['body'], true); + $this->assertSame('preauth', $body['paymentType']); + $this->assertEquals(42.0, $body['amount']); + $this->assertSame('USD', $body['currency']); + return true; + }) + ->andReturn(['response' => ['code' => 200]]); + + $result = HelcimClient::initialize_checkout(42.00, 'USD', 'preauth'); + $this->assertSame('real-checkout-token', $result['checkoutToken']); + } + + public function test_real_mode_capture_includes_an_idempotency_key(): void + { + Functions\when('getenv')->justReturn('/fake/path/to/token'); + Functions\when('is_readable')->justReturn(true); + Functions\when('file_get_contents')->justReturn('real-token-abc'); + Functions\when('wp_json_encode')->alias('json_encode'); + Functions\when('wp_generate_uuid4')->justReturn('idem-key-123'); + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_body')->justReturn(json_encode(['transactionId' => 999, 'status' => 'APPROVED'])); + + Functions\expect('wp_remote_post') + ->once() + ->withArgs(function ($url, $args) { + $this->assertSame('https://api.helcim.com/v2/payment/capture', $url); + $this->assertSame('idem-key-123', $args['headers']['idempotency-key']); + $body = json_decode($args['body'], true); + $this->assertSame(555, $body['preAuthTransactionId']); + $this->assertEquals(42.0, $body['amount']); + $this->assertSame('127.0.0.1', $body['ipAddress']); + return true; + }) + ->andReturn(['response' => ['code' => 200]]); + + $result = HelcimClient::capture(555, 42.00, '127.0.0.1'); + $this->assertSame(999, $result['transactionId']); + } + + public function test_real_mode_throws_on_an_error_response(): void + { + Functions\when('getenv')->justReturn('/fake/path/to/token'); + Functions\when('is_readable')->justReturn(true); + Functions\when('file_get_contents')->justReturn('real-token-abc'); + Functions\when('wp_json_encode')->alias('json_encode'); + Functions\when('wp_generate_uuid4')->justReturn('idem-key-123'); + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_remote_retrieve_response_code')->justReturn(422); + Functions\when('wp_remote_retrieve_body')->justReturn(json_encode(['errors' => ['card declined']])); + Functions\when('wp_remote_post')->justReturn(['response' => ['code' => 422]]); + + $this->expectException(HelcimApiException::class); + $this->expectExceptionMessageMatches('/card declined/'); + + HelcimClient::capture(555, 42.00, '127.0.0.1'); + } +} diff --git a/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimGatewayTest.php b/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimGatewayTest.php new file mode 100644 index 0000000..c580022 --- /dev/null +++ b/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimGatewayTest.php @@ -0,0 +1,84 @@ +justReturn($data); + Functions\when('delete_transient')->justReturn(true); + Functions\when('getenv')->justReturn(false); // HelcimClient stays in fake mode + Functions\when('wp_generate_uuid4')->justReturn('fake-uuid'); + Functions\when('WC')->justReturn((object) ['session' => null]); // CheckoutSession::identity() falls back to session_id() + } + + private function mock_order(): \WC_Order + { + $order = Mockery::mock(\WC_Order::class); + $order->shouldReceive('update_meta_data')->withAnyArgs(); + $order->shouldReceive('save'); + $order->shouldReceive('add_order_note')->withAnyArgs(); + $order->shouldReceive('get_meta')->withAnyArgs()->andReturn(''); + return $order; + } + + public function test_no_pending_preauth_fails_without_touching_the_order(): void + { + $this->stub_pending_preauth(null); + Functions\when('wc_get_order')->justReturn(Mockery::mock(\WC_Order::class)); + Functions\expect('wc_add_notice')->once(); + + $gateway = new HelcimGateway(); + $result = $gateway->process_payment(123); + + $this->assertSame('failure', $result['result']); + } + + public function test_fraud_pass_authorizes_the_order(): void + { + $this->stub_pending_preauth(['preauth_transaction_id' => 555, 'card_token' => 'tok_1', 'amount' => 42.00]); + Functions\when('apply_filters')->justReturn(['pass' => true, 'reason' => 'no-op: fraud plugin not installed']); + + $order = $this->mock_order(); + $order->shouldReceive('update_status')->once()->with(OrderStatuses::AUTHORIZED, Mockery::type('string')); + Functions\when('wc_get_order')->justReturn($order); + + $gateway = new HelcimGateway(); + $result = $gateway->process_payment(123); + + $this->assertSame('success', $result['result']); + $this->assertArrayHasKey('redirect', $result); + } + + public function test_fraud_fail_reverses_the_hold_and_routes_to_exception(): void + { + $this->stub_pending_preauth(['preauth_transaction_id' => 555, 'card_token' => 'tok_1', 'amount' => 42.00]); + Functions\when('apply_filters')->justReturn(['pass' => false, 'reason' => 'high risk score']); + Functions\expect('wc_add_notice')->once(); + + $order = $this->mock_order(); + $order->shouldReceive('update_status')->once()->with(OrderStatuses::EXCEPTION, Mockery::type('string')); + Functions\when('wc_get_order')->justReturn($order); + + $gateway = new HelcimGateway(); + $result = $gateway->process_payment(123); + + $this->assertSame('failure', $result['result']); + } +} diff --git a/wp-content/plugins/bookstore-core/tests/bootstrap.php b/wp-content/plugins/bookstore-core/tests/bootstrap.php index 0a4e542..d80c4e6 100644 --- a/wp-content/plugins/bookstore-core/tests/bootstrap.php +++ b/wp-content/plugins/bookstore-core/tests/bootstrap.php @@ -27,6 +27,63 @@ if (!class_exists(\WC_Cart::class)) { } } +// Payments\HelcimGateway extends this. Minimal stand-in for the real +// WC_Payment_Gateway's settings-API plumbing (init_settings()/ +// get_option()) so the constructor doesn't fatal in a unit test — real +// WooCommerce's version does much more (admin fields rendering, etc.), +// none of which the gateway's process_payment() branching depends on. +if (!class_exists(\WC_Payment_Gateway::class)) { + class WC_Payment_Gateway + { + public $id; + public $method_title; + public $method_description; + public $has_fields; + public $supports = []; + public $title; + public $description; + public $enabled; + public $form_fields = []; + public $settings = []; + + public function init_settings(): void + { + $this->settings = []; + } + + public function get_option($key, $default = '') + { + return $this->settings[$key] ?? $default; + } + + public function get_return_url($order) + { + return 'https://example.test/order-received/'; + } + + public function process_admin_options(): void + { + } + } +} + +if (!class_exists(\WP_Error::class)) { + class WP_Error + { + private string $message; + + public function __construct($code = '', $message = '') + { + $this->message = $message; + } + + public function get_error_message(): string + { + return $this->message; + } + } +} + // Membership\Tier calls $wpdb->get_row(..., ARRAY_A) — these are real // WordPress core constants (wp-includes/wp-db.php) that a full WP // bootstrap would define; this one deliberately doesn't load WordPress,