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 9683856..cda8bc0 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 deleted file mode 100644 index 565d2c6..0000000 --- a/wp-content/plugins/bookstore-core/assets/js/helcim-checkout-block.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * 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 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 {