From 21038d68cae9012522a3b2bce1a27d8f218188bf Mon Sep 17 00:00:00 2001 From: Twooey Date: Fri, 28 Aug 2026 16:59:08 -0400 Subject: [PATCH] Replace Helcim integration with WooCommerce Stripe --- .env.example | 11 +- README.md | 63 +++--- deploy/backup.sh | 2 +- deploy/deploy.sh | 71 +++++-- docker-compose.yml | 16 +- ...okstore_prelaunch_implementation_plan.docx | Bin 53715 -> 54942 bytes .../assets/js/helcim-checkout-block.js | 182 ------------------ .../plugins/bookstore-core/bookstore-core.php | 19 +- .../includes/Membership/Tier.php | 2 +- .../includes/Order/CheckoutRecheck.php | 50 +---- .../includes/Order/SupplierOrderJob.php | 96 ++++----- .../includes/Payments/CheckoutController.php | 152 --------------- .../includes/Payments/CheckoutSession.php | 51 ----- .../includes/Payments/FraudCheck.php | 11 +- .../includes/Payments/HelcimApiException.php | 9 - .../Payments/HelcimBlocksIntegration.php | 64 ------ .../includes/Payments/HelcimClient.php | 123 ------------ .../includes/Payments/HelcimGateway.php | 127 ------------ .../includes/Payments/HelcimGatewayConfig.php | 79 -------- .../includes/Pricing/PricingEngine.php | 4 +- .../tests/Unit/Payments/HelcimClientTest.php | 115 ----------- .../tests/Unit/Payments/HelcimGatewayTest.php | 84 -------- .../bookstore-core/tests/bootstrap.php | 40 ---- 23 files changed, 172 insertions(+), 1199 deletions(-) delete mode 100644 wp-content/plugins/bookstore-core/assets/js/helcim-checkout-block.js delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/CheckoutSession.php delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimBlocksIntegration.php delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimClient.php delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimGateway.php delete mode 100644 wp-content/plugins/bookstore-core/includes/Payments/HelcimGatewayConfig.php delete mode 100644 wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php delete mode 100644 wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimGatewayTest.php diff --git a/.env.example b/.env.example index df47259..cd3b0bd 100644 --- a/.env.example +++ b/.env.example @@ -51,9 +51,14 @@ BOOKSRUN_FTP_REMOTE_PATH= INGRAM_API_KEY= INGRAM_ACCOUNT_ID= -# --- Payments (Helcim) --- -HELCIM_API_TOKEN= -HELCIM_ACCOUNT_ID= +# --- Payments (official WooCommerce Stripe gateway) --- +# Use test keys in dev/staging and live keys only in production. The deploy +# writes these values to gitignored Docker secret files before configuring +# the plugin. Webhook signing secret is optional but strongly recommended. +STRIPE_MODE=test +STRIPE_PUBLISHABLE_KEY= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= # --- Marketing --- MAILERLITE_API_KEY= diff --git a/README.md b/README.md index 42edd38..4765b11 100644 --- a/README.md +++ b/README.md @@ -101,39 +101,34 @@ 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 (preauth/capture) +### Payments: WooCommerce Stripe (authorize/capture) -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. +Deployment installs and activates the official `woocommerce-gateway-stripe` +extension and enables its **Issue an authorization on checkout, and capture +later** setting. Stripe owns card collection, Checkout Block integration, +webhooks, authorization, capture, and cancellation; `bookstore-core` never +handles card data or Stripe API credentials. -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: +Set `STRIPE_MODE`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, and (when +available) `STRIPE_WEBHOOK_SECRET` in `.env.`, then deploy. The +deploy script writes them to gitignored Docker secret files and configures the +official gateway without printing the credentials. Stripe stays disabled when +either API key is missing. Use test keys until checkout, webhooks, capture, and +cancellation have been exercised end to end. -1. Sign up for a free Helcim account. -2. Helcim dashboard → **Integrations → API Configuration** → create an API - token. -3. Put it in `HELCIM_API_TOKEN` in `.env.`, then re-run - `make deploy ENV=`. +For manual API-key connections, create a Stripe webhook endpoint for +`https:///wc-api/wc_stripe`, subscribe it to the events recommended +by the WooCommerce Stripe settings screen, and place its `whsec_...` signing +secret in `STRIPE_WEBHOOK_SECRET`. The workflow has seven days from checkout +to capture an online card authorization. Manually entered keys do not provide +Stripe-to-WooCommerce payment-method synchronization or every feature of the +OAuth connection. -`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\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. +An authorized Stripe order arrives as **On hold**. `Order\SupplierOrderJob` +runs the local fraud extension point, confirms the distributor order, and +then changes the order to **Processing**, which makes the Stripe extension +capture the authorization. A distributor failure changes the order to +**Cancelled** so Stripe releases the hold, then routes it to **Needs Review**. ### Admin access restriction (production) @@ -212,11 +207,11 @@ with a bigger `--limit` continues forward instead of rescanning. Pricing ## Checkout + order engine (Week 3, payment flow revised) -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` +A synchronous supplier/margin recheck runs right before payment +(`Order\CheckoutRecheck`, price/margin only — never charges anything). +The official WooCommerce Stripe extension then holds the card and its +On-hold transition runs the fraud check (`Payments\FraudCheck`) before +moving 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 diff --git a/deploy/backup.sh b/deploy/backup.sh index b2b1a91..4df9bf2 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -43,7 +43,7 @@ $COMPOSE run --rm -T -u www-data wordpress tar -czf - -C /var/www/html/wp-conten > "$BACKUP_DIR/uploads-${TIMESTAMP}.tar.gz" echo "==> archiving environment config" -# Every API key (Booksrun/Ingram/Helcim/MailerLite/Hardcover), the WP admin +# Every API key (Booksrun/Ingram/Stripe/MailerLite/Hardcover), the WP admin # bootstrap credentials, and both DB passwords live ONLY in this one # gitignored host file — losing the host without this backed up loses all of # it, even with the DB dump and uploads intact. Same trust model as the DB diff --git a/deploy/deploy.sh b/deploy/deploy.sh index e7c45e1..1b9de8f 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -67,14 +67,36 @@ printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password" # exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent). HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)" printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token" -# Same reasoning/pattern as Hardcover above — optional, written unconditionally. -# Payments\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 - echo " (HELCIM_API_TOKEN not set in ${ENV_FILE} — Helcim gateway will install but stay unconfigured)" +# Stripe credentials are supplied through .env but mounted into the +# containers as files so secret values never become container environment +# variables. The official gateway copies them into its WordPress settings. +STRIPE_MODE_VALUE="$(env_get "$ENV_FILE" STRIPE_MODE)" +STRIPE_MODE_VALUE="${STRIPE_MODE_VALUE:-test}" +case "$STRIPE_MODE_VALUE" in + test|live) ;; + *) echo "STRIPE_MODE must be 'test' or 'live'" >&2; exit 1 ;; +esac +STRIPE_PUBLISHABLE_KEY_VALUE="$(env_get "$ENV_FILE" STRIPE_PUBLISHABLE_KEY)" +STRIPE_SECRET_KEY_VALUE="$(env_get "$ENV_FILE" STRIPE_SECRET_KEY)" +STRIPE_WEBHOOK_SECRET_VALUE="$(env_get "$ENV_FILE" STRIPE_WEBHOOK_SECRET)" +printf '%s' "$STRIPE_PUBLISHABLE_KEY_VALUE" > "$SECRETS_DIR/stripe_publishable_key" +printf '%s' "$STRIPE_SECRET_KEY_VALUE" > "$SECRETS_DIR/stripe_secret_key" +printf '%s' "$STRIPE_WEBHOOK_SECRET_VALUE" > "$SECRETS_DIR/stripe_webhook_secret" +if [[ -z "$STRIPE_PUBLISHABLE_KEY_VALUE" || -z "$STRIPE_SECRET_KEY_VALUE" ]]; then + echo " (Stripe keys not set in ${ENV_FILE} — Stripe will remain disabled)" +else + if [[ "$STRIPE_PUBLISHABLE_KEY_VALUE" != pk_${STRIPE_MODE_VALUE}_* ]]; then + echo "STRIPE_PUBLISHABLE_KEY does not match STRIPE_MODE=${STRIPE_MODE_VALUE}" >&2 + exit 1 + fi + if [[ "$STRIPE_SECRET_KEY_VALUE" != sk_${STRIPE_MODE_VALUE}_* && "$STRIPE_SECRET_KEY_VALUE" != rk_${STRIPE_MODE_VALUE}_* ]]; then + echo "STRIPE_SECRET_KEY does not match STRIPE_MODE=${STRIPE_MODE_VALUE}" >&2 + exit 1 + fi +fi +if [[ -n "$STRIPE_WEBHOOK_SECRET_VALUE" && "$STRIPE_WEBHOOK_SECRET_VALUE" != whsec_* ]]; then + echo "STRIPE_WEBHOOK_SECRET must start with whsec_" >&2 + exit 1 fi # Same pattern again — optional, written unconditionally. # Integration\BooksRunAdapter::is_configured() is false until a real key @@ -90,7 +112,7 @@ printf '%s' "$BOOKSRUN_FTP_PASSWORD_VALUE" > "$SECRETS_DIR/booksrun_ftp_password # unreadable to them. The containing directory (700, above) is what # actually keeps other host users out; these just need to be world-readable # within that already-restricted directory. -chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token" "$SECRETS_DIR/helcim_api_token" "$SECRETS_DIR/booksrun_api_key" "$SECRETS_DIR/booksrun_ftp_password" +chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password" "$SECRETS_DIR/hardcover_api_token" "$SECRETS_DIR/stripe_publishable_key" "$SECRETS_DIR/stripe_secret_key" "$SECRETS_DIR/stripe_webhook_secret" "$SECRETS_DIR/booksrun_api_key" "$SECRETS_DIR/booksrun_ftp_password" echo "==> building and starting ${ENVIRONMENT}" $COMPOSE up -d --build @@ -133,15 +155,34 @@ echo "==> installing/activating theme (Blocksy — free; Book Store starter site $WP theme install blocksy --activate echo "==> installing/activating required plugins" -# 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 install woocommerce woocommerce-gateway-stripe redis-cache blocksy-companion --activate $WP plugin activate bookstore-core $WP redis enable || true +# The distributor workflow depends on Stripe authorizing at checkout and +# capturing later when SupplierOrderJob moves the order to Processing. +# Read the mounted files inside the container so credentials never appear in +# this command line or deployment logs. +echo "==> configuring Stripe from ${ENV_FILE} (authorize and capture later)" +$WP eval ' +$read_secret = static function (string $env): string { + $path = getenv($env); + return $path && is_readable($path) ? trim((string) file_get_contents($path)) : ""; +}; +$mode = getenv("STRIPE_MODE") === "live" ? "live" : "test"; +$publishable = $read_secret("STRIPE_PUBLISHABLE_KEY_FILE"); +$secret = $read_secret("STRIPE_SECRET_KEY_FILE"); +$webhook = $read_secret("STRIPE_WEBHOOK_SECRET_FILE"); +$settings = get_option("woocommerce_stripe_settings", []); +$settings["enabled"] = $publishable !== "" && $secret !== "" ? "yes" : "no"; +$settings["capture"] = "no"; +$settings["testmode"] = $mode === "test" ? "yes" : "no"; +$settings[$mode === "test" ? "test_publishable_key" : "publishable_key"] = $publishable; +$settings[$mode === "test" ? "test_secret_key" : "secret_key"] = $secret; +$settings[$mode === "test" ? "test_webhook_secret" : "webhook_secret"] = $webhook; +update_option("woocommerce_stripe_settings", $settings); +' + echo "==> enabling WooCommerce HPOS (custom order tables)" $WP option update woocommerce_custom_orders_table_enabled yes $WP option update woocommerce_custom_orders_table_data_sync_enabled yes diff --git a/docker-compose.yml b/docker-compose.yml index 0735cbf..fa89336 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ # remain exposed to the same class of bug — nothing consumes them yet # (bookstore-core is still a stub for those), so when that code is written # it should read `_FILE` variants the same way HARDCOVER_API_TOKEN, -# HELCIM_API_TOKEN (Week 3), and BOOKSRUN_API_KEY/BOOKSRUN_FTP_PASSWORD +# BOOKSRUN_API_KEY/BOOKSRUN_FTP_PASSWORD # (Week 4) now do below. x-bookstore-env: &bookstore-env WORDPRESS_DB_HOST: db @@ -45,10 +45,12 @@ x-bookstore-env: &bookstore-env ACTIVE_SUPPLIER_ADAPTERS: ${ACTIVE_SUPPLIER_ADAPTERS:-gutenberg_test} INGRAM_API_KEY: ${INGRAM_API_KEY:-} INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-} - HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token - HELCIM_API_TOKEN_FILE: /run/secrets/helcim_api_token + STRIPE_MODE: ${STRIPE_MODE:-test} + STRIPE_PUBLISHABLE_KEY_FILE: /run/secrets/stripe_publishable_key + STRIPE_SECRET_KEY_FILE: /run/secrets/stripe_secret_key + STRIPE_WEBHOOK_SECRET_FILE: /run/secrets/stripe_webhook_secret BOOKSRUN_API_KEY_FILE: /run/secrets/booksrun_api_key # Week 4: bulk FTP inventory feed connection details — BOOKSRUN_FTP_HOST # empty means Import\BooksRunOfferSync::is_ftp_configured() is false and @@ -109,7 +111,9 @@ services: - ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/hardcover_api_token:/run/secrets/hardcover_api_token:ro - - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/helcim_api_token:/run/secrets/helcim_api_token:ro + - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_publishable_key:/run/secrets/stripe_publishable_key:ro + - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_secret_key:/run/secrets/stripe_secret_key:ro + - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_webhook_secret:/run/secrets/stripe_webhook_secret:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_api_key:/run/secrets/booksrun_api_key:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro healthcheck: @@ -166,7 +170,9 @@ services: - ./docker/cron/entrypoint.sh:/usr/local/bin/cron-entrypoint.sh:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/db_password:/run/secrets/db_password:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/hardcover_api_token:/run/secrets/hardcover_api_token:ro - - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/helcim_api_token:/run/secrets/helcim_api_token:ro + - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_publishable_key:/run/secrets/stripe_publishable_key:ro + - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_secret_key:/run/secrets/stripe_secret_key:ro + - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/stripe_webhook_secret:/run/secrets/stripe_webhook_secret:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_api_key:/run/secrets/booksrun_api_key:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro diff --git a/docs/online_bookstore_prelaunch_implementation_plan.docx b/docs/online_bookstore_prelaunch_implementation_plan.docx index 968385624d808fb953270ffae3f042ddc3c2005f..cda8bc0a99724f1d640e3890a00e99b7dd017650 100644 GIT binary patch delta 18291 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 YH2}DT0szA;?V4YLmHig#HT_WaA0LE9Hvj+t delta 17026 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 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 c9af1c2..c28d6ee 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -4,7 +4,7 @@ * Description: Owns catalog, pricing, supplier routing, and order-state business logic for the bookstore. See docs/ for the technical design document. * Version: 0.2.0 * Requires PHP: 8.1 - * Requires Plugins: woocommerce + * Requires Plugins: woocommerce, woocommerce-gateway-stripe * Author: Bookstore */ @@ -51,7 +51,6 @@ register_activation_hook(__FILE__, function () { add_action('plugins_loaded', function () { \Bookstore\Core\Schema::maybe_upgrade(); - \Bookstore\Core\Payments\HelcimGatewayConfig::ensure_configured_from_env(); }); add_filter('woocommerce_shipping_methods', function (array $methods) { @@ -59,22 +58,6 @@ 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(); diff --git a/wp-content/plugins/bookstore-core/includes/Membership/Tier.php b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php index e777738..765ea64 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\HelcimGatewayConfig). + * never-overwrite convention used by other environment-seeded settings). */ public static function seed_defaults(): void { diff --git a/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php b/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php index b1c7162..8e1dbb9 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php +++ b/wp-content/plugins/bookstore-core/includes/Order/CheckoutRecheck.php @@ -2,10 +2,6 @@ namespace Bookstore\Core\Order; -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; @@ -13,14 +9,9 @@ defined('ABSPATH') || exit; /** * 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. + * immediately before the customer's card is authorized (held)." Fraud + * review runs after Stripe places the order on hold. This class owns the + * pre-payment price and margin validation only. * * Two checkout implementations, two different hook sets — confirmed by * reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php @@ -144,12 +135,6 @@ class CheckoutRecheck * 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 { @@ -174,8 +159,8 @@ class CheckoutRecheck /** * Shared by both checkout implementations. Returns null on success * (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 + * (order already routed to the exception status) 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. @@ -195,14 +180,13 @@ 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. Any card hold has been released — please try again.'; + return 'Sorry, part of your order is no longer available at its listed price. Please try again.'; } $offer = $recheck->offer; @@ -232,28 +216,6 @@ 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/SupplierOrderJob.php b/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php index acbab78..5184489 100644 --- a/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php +++ b/wp-content/plugins/bookstore-core/includes/Order/SupplierOrderJob.php @@ -3,9 +3,7 @@ namespace Bookstore\Core\Order; use Bookstore\Core\Catalog\SupplierOffer; -use Bookstore\Core\Payments\HelcimApiException; -use Bookstore\Core\Payments\HelcimClient; -use Bookstore\Core\Payments\HelcimGateway; +use Bookstore\Core\Payments\FraudCheck; defined('ABSPATH') || exit; @@ -20,24 +18,42 @@ defined('ABSPATH') || exit; * 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. + * The official WooCommerce Stripe gateway creates an On hold order when + * manual capture is enabled. We fraud-check that authorized order, move it + * into the bookstore's Authorized state, and submit it to the supplier. + * Moving a successful order to Processing asks the Stripe plugin to capture + * the authorization; cancelling a failed one asks Stripe to release it. */ class SupplierOrderJob { public static function register(): void { + add_action('woocommerce_order_status_on-hold', [self::class, 'accept_stripe_authorization']); add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']); add_action('bsc_submit_supplier_order', [self::class, 'run']); } + /** Promote only manually authorized orders from the official Stripe gateway. */ + public static function accept_stripe_authorization(int $order_id): void + { + $order = wc_get_order($order_id); + if ( + !$order + || !self::is_stripe_order($order) + || $order->get_meta('_stripe_charge_captured') !== 'no' + ) { + return; + } + + $fraud = FraudCheck::run($order); + if (!$fraud['pass']) { + self::fail_and_release($order, 'Fraud check failed: ' . $fraud['reason']); + return; + } + + $order->update_status(OrderStatuses::AUTHORIZED, 'Stripe authorized the payment; awaiting supplier confirmation.'); + } + /** * WooCommerce's per-status transition hooks can fire more than once * for the same order (e.g. a status re-saved without actually @@ -105,11 +121,9 @@ class SupplierOrderJob } /** - * 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. + * The supplier order just succeeded — this is the first point the card + * is actually charged. The official Stripe plugin captures when an + * on-hold/manual-capture order transitions to Processing. * * Capture failing here (card expired/declined during the hold window * — rare, but real) is its own distinct failure mode from a supplier- @@ -122,42 +136,38 @@ class SupplierOrderJob */ 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(); + $order->update_status('processing', 'Supplier confirmed; requesting Stripe capture.'); - 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." - ); + // Stripe's capture hook is synchronous and persists this flag. Reload + // so a failed capture cannot be mistaken for a successful transition. + $order = wc_get_order($order->get_id()); + if (!$order || $order->get_meta('_stripe_charge_captured') !== 'yes') { + if ($order) { + $order->update_status( + OrderStatuses::EXCEPTION, + 'Supplier order succeeded but Stripe capture was not confirmed — 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); + $order->update_status(OrderStatuses::SUPPLIER_ORDERED, 'Stripe payment captured.'); } /** - * 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." + * Nothing has been charged yet. The Stripe extension releases an + * uncaptured authorization when the order becomes Cancelled. */ private static function fail_and_release(\WC_Order $order, string $reason): void { - $order->update_status(OrderStatuses::EXCEPTION, $reason); + $order->update_status('cancelled', $reason . ' Releasing the Stripe authorization.'); + AuditTrail::record_void($order->get_id()); + $order->update_status(OrderStatuses::EXCEPTION, 'Stripe authorization release requested; order requires review.'); + } - $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( - 'Could not release the card hold (' . $e->getMessage() . ') — it will expire on its own within 7 days; no manual refund needed.' - ); - } + private static function is_stripe_order(\WC_Order $order): bool + { + $method = $order->get_payment_method(); + return $method === 'stripe' || str_starts_with($method, 'stripe_'); } } diff --git a/wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php b/wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php deleted file mode 100644 index 0b8187b..0000000 --- a/wp-content/plugins/bookstore-core/includes/Payments/CheckoutController.php +++ /dev/null @@ -1,152 +0,0 @@ - '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 deleted file mode 100644 index 066a69a..0000000 --- a/wp-content/plugins/bookstore-core/includes/Payments/CheckoutSession.php +++ /dev/null @@ -1,51 +0,0 @@ -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 a421803..74bd8b6 100644 --- a/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php +++ b/wp-content/plugins/bookstore-core/includes/Payments/FraudCheck.php @@ -5,18 +5,15 @@ namespace Bookstore\Core\Payments; defined('ABSPATH') || exit; /** - * Design doc §05: "local WooCommerce fraud checks plus Helcim's - * payment-side risk signals." Deliberately a no-op by default for Week 3 + * Design doc §05 local WooCommerce fraud-check extension point. It is a + * no-op by default for Week 3 * — the user has decided not to build custom fraud heuristics now, only * the extension point an optional paid plugin (Shop Defender) plugs into * later via add_filter('bsc_fraud_check_result', ...). The step still * runs and is logged on every order, unconditionally, so "Fraud Check" is * a real, auditable stage in the state machine rather than an absent one. - * 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. + * Called after Stripe authorizes the card and places the order on hold, + * before the order proceeds to supplier submission. */ class FraudCheck { diff --git a/wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php deleted file mode 100644 index d682bcb..0000000 --- a/wp-content/plugins/bookstore-core/includes/Payments/HelcimApiException.php +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 382c4e9..0000000 --- a/wp-content/plugins/bookstore-core/includes/Payments/HelcimClient.php +++ /dev/null @@ -1,123 +0,0 @@ - {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 deleted file mode 100644 index a2e5670..0000000 --- a/wp-content/plugins/bookstore-core/includes/Payments/HelcimGateway.php +++ /dev/null @@ -1,127 +0,0 @@ -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/HelcimGatewayConfig.php b/wp-content/plugins/bookstore-core/includes/Payments/HelcimGatewayConfig.php deleted file mode 100644 index cb22a54..0000000 --- a/wp-content/plugins/bookstore-core/includes/Payments/HelcimGatewayConfig.php +++ /dev/null @@ -1,79 +0,0 @@ -/environ`, a 644 file inside a 700 secrets/ dir - * isn't. - */ -class HelcimGatewayConfig -{ - 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; - } - - public static function token(): ?string - { - $file = getenv('HELCIM_API_TOKEN_FILE'); - if (!$file || !is_readable($file)) { - return null; - } - $value = trim((string) file_get_contents($file)); - return $value !== '' ? $value : null; - } - - /** - * Fills in only what's still unset — never overwrites a value a human - * already changed by hand in wp-admin. 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 - { - $token = self::token(); - if ($token === null) { - return; - } - - $settings = get_option(self::OPTION_NAME, []); - if (!is_array($settings)) { - $settings = []; - } - - $settings += [ - 'enabled' => 'no', // a human opts in explicitly once ready to test live at checkout - 'title' => 'Credit Card', - 'description' => '', - 'environment' => 'sandbox', - ]; - - if (empty($settings['sandbox_api_token'])) { - $settings['sandbox_api_token'] = $token; - } - - update_option(self::OPTION_NAME, $settings); - } -} diff --git a/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php b/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php index d2c5534..a235132 100644 --- a/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php +++ b/wp-content/plugins/bookstore-core/includes/Pricing/PricingEngine.php @@ -10,7 +10,7 @@ defined('ABSPATH') || exit; /** * Design doc §05: Retail Price = (Supplier Base + Supplier Shipping + * Fixed Processing) / (1 - Processing Rate - Target Margin). Processing - * Rate is a placeholder blended estimate — tune from real Helcim + * Rate is a placeholder blended estimate — tune from real Stripe * settlement data once it exists, not from guessing. */ class PricingEngine @@ -58,7 +58,7 @@ class PricingEngine } /** - * Week 3: what calculate_retail_price() assumed Helcim's cut would be + * Week 3: what calculate_retail_price() assumed Stripe's cut would be * for a given selling price, for the order-audit ledger * (Order\AuditTrail) to compare against actual settlement later. * Exposed as its own method (rather than inlining the formula at every diff --git a/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php b/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php deleted file mode 100644 index 3fc7052..0000000 --- a/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimClientTest.php +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index c580022..0000000 --- a/wp-content/plugins/bookstore-core/tests/Unit/Payments/HelcimGatewayTest.php +++ /dev/null @@ -1,84 +0,0 @@ -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 d80c4e6..1c96eba 100644 --- a/wp-content/plugins/bookstore-core/tests/bootstrap.php +++ b/wp-content/plugins/bookstore-core/tests/bootstrap.php @@ -27,46 +27,6 @@ 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 {