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 864b2a4..9683856 100644 Binary files a/docs/online_bookstore_prelaunch_implementation_plan.docx and b/docs/online_bookstore_prelaunch_implementation_plan.docx differ 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,