Replace Helcim integration with WooCommerce Stripe

This commit is contained in:
twooey committed 2026-08-28 16:59:08 -04:00
1 parent 197938f482
commit 21038d68ca
23 files changed
+172 -1199

No files matched your search

+8 -3
View File
@@ -51,9 +51,14 @@ BOOKSRUN_FTP_REMOTE_PATH=
INGRAM_API_KEY= INGRAM_API_KEY=
INGRAM_ACCOUNT_ID= INGRAM_ACCOUNT_ID=
# --- Payments (Helcim) --- # --- Payments (official WooCommerce Stripe gateway) ---
HELCIM_API_TOKEN= # Use test keys in dev/staging and live keys only in production. The deploy
HELCIM_ACCOUNT_ID= # 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 --- # --- Marketing ---
MAILERLITE_API_KEY= MAILERLITE_API_KEY=
+29 -34
View File
@@ -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 the database like any other WordPress content — a redeploy or a fresh
`deploy.sh` run doesn't touch it or need to repeat it. `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 Deployment installs and activates the official `woocommerce-gateway-stripe`
`bookstore-core` (`Payments\HelcimGateway`, built directly against extension and enables its **Issue an authorization on checkout, and capture
HelcimPay.js) — not the off-the-shelf `helcim-commerce-for-woocommerce` later** setting. Stripe owns card collection, Checkout Block integration,
plugin, which `deploy.sh` deliberately does not install (it only webhooks, authorization, capture, and cancellation; `bookstore-core` never
supports immediate charges; see design doc Appendix B). Checkout handles card data or Stripe API credentials.
authorizes (holds) the card; the charge only actually happens once the
supplier order succeeds (`Order\SupplierOrderJob`) — see that class and
`Payments\HelcimClient`'s docblocks for the full sequence.
The gateway needs a Helcim **sandbox API token**, which isn't something a Set `STRIPE_MODE`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, and (when
public API can hand out, so it's a one-time manual step per environment: available) `STRIPE_WEBHOOK_SECRET` in `.env.<environment>`, 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. For manual API-key connections, create a Stripe webhook endpoint for
2. Helcim dashboard → **Integrations → API Configuration** → create an API `https://<store-host>/wc-api/wc_stripe`, subscribe it to the events recommended
token. by the WooCommerce Stripe settings screen, and place its `whsec_...` signing
3. Put it in `HELCIM_API_TOKEN` in `.env.<environment>`, then re-run secret in `STRIPE_WEBHOOK_SECRET`. The workflow has seven days from checkout
`make deploy ENV=<environment>`. 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 An authorized Stripe order arrives as **On hold**. `Order\SupplierOrderJob`
already does for `HARDCOVER_API_TOKEN` (see `secrets/<env>/` above), and runs the local fraud extension point, confirms the distributor order, and
`Payments\HelcimGatewayConfig::ensure_configured_from_env()` seeds the then changes the order to **Processing**, which makes the Stripe extension
gateway's WooCommerce settings (sandbox mode, disabled by default — a capture the authorization. A distributor failure changes the order to
human still enables it at checkout once ready) the first time it finds a **Cancelled** so Stripe releases the hold, then routes it to **Needs Review**.
token. **Until a token exists, `Payments\HelcimClient` runs in fake
mode** — every preauth/capture/reverse call returns a synthetic approved
response instead of hitting the real API, so the whole checkout ->
preauth -> fraud review -> supplier order -> capture/reverse flow is
testable end-to-end without one (same pattern as the synthetic
`gutenberg_test` supplier). Go-live (switching from sandbox to Helcim's
live environment) is a deliberate manual step on the settings screen,
not something this repo automates.
### Admin access restriction (production) ### 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) ## Checkout + order engine (Week 3, payment flow revised)
A synchronous pre-authorization supplier/margin recheck runs right A synchronous supplier/margin recheck runs right before payment
before the card is held (`Order\CheckoutRecheck`, price/margin only — (`Order\CheckoutRecheck`, price/margin only — never charges anything).
never charges anything). The custom Helcim gateway (see "Payments: The official WooCommerce Stripe extension then holds the card and its
Helcim" above) then holds the card and, once the fraud check On-hold transition runs the fraud check (`Payments\FraudCheck`) before
(`Payments\FraudCheck`) passes, moves the order to `bsc-authorized` moving the order to `bsc-authorized`
(held, not charged). `Order\SupplierOrderJob` picks it up from there: (held, not charged). `Order\SupplierOrderJob` picks it up from there:
submits to the supplier, and only on success **captures** the hold (the submits to the supplier, and only on success **captures** the hold (the
card is actually charged for the first time here) before moving to card is actually charged for the first time here) before moving to
+1 -1
View File
@@ -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" > "$BACKUP_DIR/uploads-${TIMESTAMP}.tar.gz"
echo "==> archiving environment config" 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 # bootstrap credentials, and both DB passwords live ONLY in this one
# gitignored host file — losing the host without this backed up loses all of # 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 # it, even with the DB dump and uploads intact. Same trust model as the DB
+56 -15
View File
@@ -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). # exact bug ENVIRONMENT-unset guards elsewhere in this file exist to prevent).
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)" HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token" printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token"
# Same reasoning/pattern as Hardcover above — optional, written unconditionally. # Stripe credentials are supplied through .env but mounted into the
# Payments\HelcimGatewayConfig::is_configured() is false (and the # containers as files so secret values never become container environment
# gateway falls back to fake mode — see HelcimClient) until a real # variables. The official gateway copies them into its WordPress settings.
# sandbox token lands in HELCIM_API_TOKEN. STRIPE_MODE_VALUE="$(env_get "$ENV_FILE" STRIPE_MODE)"
HELCIM_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HELCIM_API_TOKEN)" STRIPE_MODE_VALUE="${STRIPE_MODE_VALUE:-test}"
printf '%s' "$HELCIM_API_TOKEN_VALUE" > "$SECRETS_DIR/helcim_api_token" case "$STRIPE_MODE_VALUE" in
if [[ -z "$HELCIM_API_TOKEN_VALUE" ]]; then test|live) ;;
echo " (HELCIM_API_TOKEN not set in ${ENV_FILE} — Helcim gateway will install but stay unconfigured)" *) 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 fi
# Same pattern again — optional, written unconditionally. # Same pattern again — optional, written unconditionally.
# Integration\BooksRunAdapter::is_configured() is false until a real key # 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 # unreadable to them. The containing directory (700, above) is what
# actually keeps other host users out; these just need to be world-readable # actually keeps other host users out; these just need to be world-readable
# within that already-restricted directory. # 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}" echo "==> building and starting ${ENVIRONMENT}"
$COMPOSE up -d --build $COMPOSE up -d --build
@@ -133,15 +155,34 @@ echo "==> installing/activating theme (Blocksy — free; Book Store starter site
$WP theme install blocksy --activate $WP theme install blocksy --activate
echo "==> installing/activating required plugins" echo "==> installing/activating required plugins"
# helcim-commerce-for-woocommerce (the off-the-shelf, immediate-charge- $WP plugin install woocommerce woocommerce-gateway-stripe redis-cache blocksy-companion --activate
# only plugin) is deliberately NOT installed here anymore — Payments\
# HelcimGateway (shipped inside bookstore-core) replaces it with a
# preauth-then-capture flow built directly against HelcimPay.js. See
# design doc Appendix B's revised decision.
$WP plugin install woocommerce redis-cache blocksy-companion --activate
$WP plugin activate bookstore-core $WP plugin activate bookstore-core
$WP redis enable || true $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)" echo "==> enabling WooCommerce HPOS (custom order tables)"
$WP option update woocommerce_custom_orders_table_enabled yes $WP option update woocommerce_custom_orders_table_enabled yes
$WP option update woocommerce_custom_orders_table_data_sync_enabled yes $WP option update woocommerce_custom_orders_table_data_sync_enabled yes
+11 -5
View File
@@ -26,7 +26,7 @@
# remain exposed to the same class of bug — nothing consumes them yet # 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 # (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, # 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. # (Week 4) now do below.
x-bookstore-env: &bookstore-env x-bookstore-env: &bookstore-env
WORDPRESS_DB_HOST: db WORDPRESS_DB_HOST: db
@@ -45,10 +45,12 @@ x-bookstore-env: &bookstore-env
ACTIVE_SUPPLIER_ADAPTERS: ${ACTIVE_SUPPLIER_ADAPTERS:-gutenberg_test} ACTIVE_SUPPLIER_ADAPTERS: ${ACTIVE_SUPPLIER_ADAPTERS:-gutenberg_test}
INGRAM_API_KEY: ${INGRAM_API_KEY:-} INGRAM_API_KEY: ${INGRAM_API_KEY:-}
INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-} INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-}
HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-}
MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-}
HARDCOVER_API_TOKEN_FILE: /run/secrets/hardcover_api_token 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 BOOKSRUN_API_KEY_FILE: /run/secrets/booksrun_api_key
# Week 4: bulk FTP inventory feed connection details — BOOKSRUN_FTP_HOST # Week 4: bulk FTP inventory feed connection details — BOOKSRUN_FTP_HOST
# empty means Import\BooksRunOfferSync::is_ftp_configured() is false and # 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 - ./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}/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}/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_api_key:/run/secrets/booksrun_api_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro
healthcheck: healthcheck:
@@ -166,7 +170,9 @@ services:
- ./docker/cron/entrypoint.sh:/usr/local/bin/cron-entrypoint.sh:ro - ./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}/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}/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_api_key:/run/secrets/booksrun_api_key:ro
- ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro - ./secrets/${ENVIRONMENT:?ENVIRONMENT must be set}/booksrun_ftp_password:/run/secrets/booksrun_ftp_password:ro
@@ -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'],
},
});
})();
@@ -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. * 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 * Version: 0.2.0
* Requires PHP: 8.1 * Requires PHP: 8.1
* Requires Plugins: woocommerce * Requires Plugins: woocommerce, woocommerce-gateway-stripe
* Author: Bookstore * Author: Bookstore
*/ */
@@ -51,7 +51,6 @@ register_activation_hook(__FILE__, function () {
add_action('plugins_loaded', function () { add_action('plugins_loaded', function () {
\Bookstore\Core\Schema::maybe_upgrade(); \Bookstore\Core\Schema::maybe_upgrade();
\Bookstore\Core\Payments\HelcimGatewayConfig::ensure_configured_from_env();
}); });
add_filter('woocommerce_shipping_methods', function (array $methods) { add_filter('woocommerce_shipping_methods', function (array $methods) {
@@ -59,22 +58,6 @@ add_filter('woocommerce_shipping_methods', function (array $methods) {
return $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/). // Membership tiers/benefits: design doc §07 extension (see includes/Membership/).
\Bookstore\Core\Membership\MemberDiscount::register(); \Bookstore\Core\Membership\MemberDiscount::register();
\Bookstore\Core\Membership\Registration::register(); \Bookstore\Core\Membership\Registration::register();
@@ -198,7 +198,7 @@ class Tier
* Seeds guest/free_member with today's real defaults — only if * Seeds guest/free_member with today's real defaults — only if
* missing by slug, so a human's later edits via the CLI/settings * missing by slug, so a human's later edits via the CLI/settings
* screen are never clobbered on a later plugins_loaded (same * 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 public static function seed_defaults(): void
{ {
@@ -2,10 +2,6 @@
namespace Bookstore\Core\Order; 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; use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -13,14 +9,9 @@ defined('ABSPATH') || exit;
/** /**
* Appendix B (revised for the preauth-then-capture rework): "Supplier * Appendix B (revised for the preauth-then-capture rework): "Supplier
* stock, price, and margin are rechecked live, synchronously, * stock, price, and margin are rechecked live, synchronously,
* immediately before the customer's card is authorized (held)." The * immediately before the customer's card is authorized (held)." Fraud
* fraud check moved out of run_checks() and into * review runs after Stripe places the order on hold. This class owns the
* Payments\HelcimGateway::process_payment() — it now runs *after* the * pre-payment price and margin validation only.
* preauth exists rather than before, per the new checkout -> preauth ->
* fraud review -> supplier order -> capture sequence. This class still
* owns price/margin only, and (new) reversing any preauth that's
* already been taken if that recheck fails here — see the "wrinkle"
* note on validate_order_before_payment() below.
* *
* Two checkout implementations, two different hook sets — confirmed by * Two checkout implementations, two different hook sets — confirmed by
* reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php * 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 * guards — see that class), which is what the design doc's "duplicate
* webhook/job... do not double-order" requirement is actually about. * 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 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 * Shared by both checkout implementations. Returns null on success
* (audit rows already written) or a customer-facing failure message * (audit rows already written) or a customer-facing failure message
* (order already routed to the exception status, any preauth already * (order already routed to the exception status) on failure. Audit rows
* reversed) on failure. Audit rows are only ever inserted after every * are only ever inserted after every
* item has passed — never partially, which matters because a failed * item has passed — never partially, which matters because a failed
* attempt can be resubmitted against the same draft order under * attempt can be resubmitted against the same draft order under
* Blocks checkout. * Blocks checkout.
@@ -195,14 +180,13 @@ class CheckoutRecheck
$quoted_unit = (float) $item->get_total() / $quantity; $quoted_unit = (float) $item->get_total() / $quantity;
if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) { if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) {
self::reverse_pending_preauth();
self::route_to_exception($order, sprintf( self::route_to_exception($order, sprintf(
'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).', 'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).',
$item->get_name(), $item->get_name(),
$quoted_unit, $quoted_unit,
$recheck->price ?? 0.0 $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; $offer = $recheck->offer;
@@ -232,28 +216,6 @@ class CheckoutRecheck
return null; 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 private static function route_to_exception(\WC_Order $order, string $reason): void
{ {
$order->update_status(OrderStatuses::EXCEPTION, $reason); $order->update_status(OrderStatuses::EXCEPTION, $reason);
@@ -3,9 +3,7 @@
namespace Bookstore\Core\Order; namespace Bookstore\Core\Order;
use Bookstore\Core\Catalog\SupplierOffer; use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Payments\HelcimApiException; use Bookstore\Core\Payments\FraudCheck;
use Bookstore\Core\Payments\HelcimClient;
use Bookstore\Core\Payments\HelcimGateway;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -20,24 +18,42 @@ defined('ABSPATH') || exit;
* safely via queue; do not double-order" idempotency requirement actually * safely via queue; do not double-order" idempotency requirement actually
* lives, ahead of Week 4 needing it for real. * lives, ahead of Week 4 needing it for real.
* *
* Preauth-then-capture rework: this class now runs BEFORE the card is * The official WooCommerce Stripe gateway creates an On hold order when
* actually charged, not after. It triggers on the order reaching * manual capture is enabled. We fraud-check that authorized order, move it
* Order\OrderStatuses::AUTHORIZED (preauth held, fraud check already * into the bookstore's Authorized state, and submit it to the supplier.
* passed — Payments\HelcimGateway) rather than woocommerce_payment_complete. * Moving a successful order to Processing asks the Stripe plugin to capture
* A successful supplier submission captures the held funds for the first * the authorization; cancelling a failed one asks Stripe to release it.
* time (payment_complete() only fires here now, at capture); a failed one
* reverses the hold instead of refunding a charge that was never made —
* the whole point of this rework, replacing Appendix B's accepted
* "refund, lose the processing fee" risk with a genuinely zero-cost void.
*/ */
class SupplierOrderJob class SupplierOrderJob
{ {
public static function register(): void 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('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']);
add_action('bsc_submit_supplier_order', [self::class, 'run']); 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 * WooCommerce's per-status transition hooks can fire more than once
* for the same order (e.g. a status re-saved without actually * 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 * The supplier order just succeeded — this is the first point the card
* card is actually charged. Captures the preauth held since checkout, * is actually charged. The official Stripe plugin captures when an
* then calls WC's own payment_complete() (which now fires * on-hold/manual-capture order transitions to Processing.
* woocommerce_payment_complete for real, restoring that hook's
* original meaning) before moving to Supplier Ordered.
* *
* Capture failing here (card expired/declined during the hold window * Capture failing here (card expired/declined during the hold window
* — rare, but real) is its own distinct failure mode from a supplier- * — 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 private static function capture_and_complete(\WC_Order $order): void
{ {
$preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id'); $order->update_status('processing', 'Supplier confirmed; requesting Stripe capture.');
$amount = (float) $order->get_total();
try { // Stripe's capture hook is synchronous and persists this flag. Reload
$capture = HelcimClient::capture($preauth_id, $amount, HelcimGateway::client_ip()); // so a failed capture cannot be mistaken for a successful transition.
} catch (HelcimApiException $e) { $order = wc_get_order($order->get_id());
$order->update_status( if (!$order || $order->get_meta('_stripe_charge_captured') !== 'yes') {
OrderStatuses::EXCEPTION, if ($order) {
"Supplier order succeeded but capture failed ({$e->getMessage()}) — needs manual follow-up, do not resubmit to the supplier." $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; return;
} }
$order->payment_complete((string) ($capture['transactionId'] ?? $preauth_id)); $order->update_status(OrderStatuses::SUPPLIER_ORDERED, 'Stripe payment captured.');
$order->update_status(OrderStatuses::SUPPLIER_ORDERED);
} }
/** /**
* Appendix B's residual risk ("a charge that later needs to be * Nothing has been charged yet. The Stripe extension releases an
* undone becomes a refund... the store loses only the processing * uncaptured authorization when the order becomes Cancelled.
* fee") is exactly what this rework was for: nothing has been
* charged yet at this point, so a failed supplier order releases the
* hold instead — genuinely zero cost, not "only the processing fee."
*/ */
private static function fail_and_release(\WC_Order $order, string $reason): void 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'); private static function is_stripe_order(\WC_Order $order): bool
try { {
HelcimClient::reverse($preauth_id, HelcimGateway::client_ip()); $method = $order->get_payment_method();
AuditTrail::record_void($order->get_id()); return $method === 'stripe' || str_starts_with($method, 'stripe_');
} 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.'
);
}
} }
} }
@@ -1,152 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* The two REST endpoints the Blocks checkout JS (assets/js/helcim-checkout-block.js)
* calls to get a validated preauth before the WooCommerce order is even
* submitted — see Payments\CheckoutSession's docblock for why this has
* to happen before order creation rather than inside process_payment().
*
* init: server-side call to HelcimClient::initialize_checkout() (keeps
* the API token off the client entirely) for the current cart total;
* returns checkoutToken + secretToken to the client — this matches
* Helcim's own documented HelcimPay.js pattern (their reference
* validateResponse() example sends secretToken back to your endpoint
* alongside the raw transaction response, rather than keeping it purely
* server-side), not a deviation from it.
* confirm: the client forwards {rawDataResponse, checkoutToken,
* secretToken} here after HelcimPay.js's iframe completes (or, in fake
* mode, after clicking "Simulate card entry"). Hash validation per
* Helcim's documented algorithm: sha256(json_encode(rawDataResponse) .
* secretToken), compared against the response's own hash field — the
* exact field Helcim compares it to wasn't shown verbatim in what's
* accessible without a real sandbox account, so this is a best-effort
* implementation of their documented algorithm, not independently
* confirmed against a real transaction yet.
*/
class CheckoutController
{
public static function register(): void
{
add_action('rest_api_init', [self::class, 'register_routes']);
}
public static function register_routes(): void
{
register_rest_route('bsc/v1', '/helcim/init', [
'methods' => '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']);
}
}
@@ -1,51 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* The preauth has to exist before the WooCommerce order does (the
* customer is present entering card details in the HelcimPay.js iframe
* before submitting checkout), but Order\CheckoutRecheck's price/margin
* validation still runs after that, right before process_payment() — if
* it fails there, a live uncaptured hold already exists and must be
* reversed even though process_payment() is never reached. A short-lived
* transient, keyed by the same customer-session identity
* Order\CheckoutRecheck::lock_key() already uses for its advisory lock,
* is what lets both HelcimGateway::process_payment() and
* CheckoutRecheck's failure path find (and clear) the same pending
* preauth regardless of which one runs first.
*/
class CheckoutSession
{
private const TTL = 20 * MINUTE_IN_SECONDS;
public static function identity(): string
{
return WC()->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());
}
}
@@ -5,18 +5,15 @@ namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Design doc §05: "local WooCommerce fraud checks plus Helcim's * Design doc §05 local WooCommerce fraud-check extension point. It is a
* payment-side risk signals." Deliberately a no-op by default for Week 3 * no-op by default for Week 3
* — the user has decided not to build custom fraud heuristics now, only * — the user has decided not to build custom fraud heuristics now, only
* the extension point an optional paid plugin (Shop Defender) plugs into * the extension point an optional paid plugin (Shop Defender) plugs into
* later via add_filter('bsc_fraud_check_result', ...). The step still * later via add_filter('bsc_fraud_check_result', ...). The step still
* runs and is logged on every order, unconditionally, so "Fraud Check" is * 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. * a real, auditable stage in the state machine rather than an absent one.
* Called from HelcimGateway::process_payment(), right after the card is * Called after Stripe authorizes the card and places the order on hold,
* preauthorized (held) but before the order is allowed to proceed to * before the order proceeds to supplier submission.
* Authorized status — moved here from Order\CheckoutRecheck by the
* preauth-then-capture rework: fraud review now runs after the hold
* exists, not before, per design doc Appendix B's revised sequence.
*/ */
class FraudCheck class FraudCheck
{ {
@@ -1,9 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
class HelcimApiException extends \RuntimeException
{
}
@@ -1,64 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
defined('ABSPATH') || exit;
/**
* Blocks/Store API side of Payments\HelcimGateway — this store only
* serves Cart/Checkout blocks (confirmed live, no shortcode checkout
* exists — see Order\CheckoutRecheck's docblock), so a plain
* WC_Payment_Gateway alone isn't enough to show up at checkout; it also
* needs registering here plus a matching JS bundle
* (assets/js/helcim-checkout-block.js) via
* wc.wcBlocksRegistry.registerPaymentMethod. process_payment() on the
* gateway itself is still what actually runs server-side — this class
* only handles the block rendering/script-enqueueing side.
*/
class HelcimBlocksIntegration extends AbstractPaymentMethodType
{
protected $name = 'bsc_helcim';
public function initialize(): void
{
$this->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'] ?? '',
];
}
}
@@ -1,123 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* Thin wrapper around Helcim's Payment API (devdocs.helcim.com), built
* directly against HelcimPay.js/the raw API rather than the off-the-shelf
* helcim-commerce-for-woocommerce plugin — see design doc Appendix B's
* revised decision. Endpoints/parameters confirmed against Helcim's
* current developer docs:
* - POST /v2/helcim-pay/initialize {paymentType, amount, currency}
* -> {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;
}
}
@@ -1,127 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
use Bookstore\Core\Order\OrderStatuses;
defined('ABSPATH') || exit;
/**
* Custom WC_Payment_Gateway against HelcimPay.js directly (id
* `bsc_helcim`) — replaces the off-the-shelf helcim-commerce-for-
* woocommerce plugin's immediate-charge flow. See design doc Appendix
* B's revised decision and Payments\HelcimClient's docblock (fake mode).
*
* process_payment() does NOT charge the card. By the time it runs, the
* customer has already completed a preauth via the Blocks checkout JS
* (assets/js/helcim-checkout-block.js) — Payments\CheckoutController's
* /confirm endpoint already validated it and stashed it in
* Payments\CheckoutSession. This method's job is: pick up that pending
* preauth, run the (moved-here) fraud check, and either reverse the hold
* and route to review, or mark the order Authorized and let
* Order\SupplierOrderJob take it from there — capture only happens
* after a successful supplier order.
*/
class HelcimGateway extends \WC_Payment_Gateway
{
public function __construct()
{
$this->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';
}
}
@@ -1,79 +0,0 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* Replaces Payments\HelcimConfig (which seeded settings for the
* off-the-shelf helcim-commerce-for-woocommerce plugin — removed, see
* design doc Appendix B's revised decision: that plugin only supports
* immediate-charge "Purchase" transactions, not the preauth/capture flow
* this now uses, so a custom gateway (Payments\HelcimGateway) replaces
* it rather than extending it). Gateway id `bsc_helcim`, settings option
* `woocommerce_bsc_helcim_settings` — our own, not the old plugin's.
*
* _FILE env-var reading mirrors Integration\HardcoverAdapter::token()
* exactly, same reasoning: a plain ${VAR} is visible via `docker
* inspect`/`/proc/<pid>/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);
}
}
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
/** /**
* Design doc §05: Retail Price = (Supplier Base + Supplier Shipping + * Design doc §05: Retail Price = (Supplier Base + Supplier Shipping +
* Fixed Processing) / (1 - Processing Rate - Target Margin). Processing * 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. * settlement data once it exists, not from guessing.
*/ */
class PricingEngine 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 * for a given selling price, for the order-audit ledger
* (Order\AuditTrail) to compare against actual settlement later. * (Order\AuditTrail) to compare against actual settlement later.
* Exposed as its own method (rather than inlining the formula at every * Exposed as its own method (rather than inlining the formula at every
@@ -1,115 +0,0 @@
<?php
namespace Bookstore\Core\Tests\Unit\Payments;
use Bookstore\Core\Payments\HelcimApiException;
use Bookstore\Core\Payments\HelcimClient;
use Bookstore\Core\Tests\TestCase;
use Brain\Monkey\Functions;
/**
* HelcimGatewayConfig::token() reads getenv('HELCIM_API_TOKEN_FILE') —
* mocking that directly is what switches HelcimClient between fake mode
* (no token: every call returns a synthetic approved response, no
* network call) and real mode (a token: real wp_remote_post calls with
* the shape confirmed against Helcim's current developer docs).
*/
class HelcimClientTest extends TestCase
{
public function test_fake_mode_never_calls_the_real_api(): void
{
Functions\when('getenv')->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');
}
}
@@ -1,84 +0,0 @@
<?php
namespace Bookstore\Core\Tests\Unit\Payments;
use Bookstore\Core\Order\OrderStatuses;
use Bookstore\Core\Payments\HelcimGateway;
use Bookstore\Core\Tests\TestCase;
use Brain\Monkey\Functions;
use Mockery;
/**
* process_payment()'s branching: pick up the preauth CheckoutController
* already stashed (Payments\CheckoutSession, backed by get/set/delete_
* transient — mocked at that WP-function layer, not by mocking
* CheckoutSession itself, matching this suite's established pattern),
* run the (moved-here) fraud check, and either authorize the order or
* reverse the hold and route to review. HelcimClient runs in fake mode
* here (no token env) so no real network call is involved either way.
*/
class HelcimGatewayTest extends TestCase
{
private function stub_pending_preauth(?array $data): void
{
Functions\when('get_transient')->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']);
}
}
@@ -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)) { if (!class_exists(\WP_Error::class)) {
class WP_Error class WP_Error
{ {