pushing stuff that was worked on

This commit is contained in:
twooey committed 2026-08-28 15:17:46 -04:00
1 parent 773883a0f3
commit 197938f482
22 files changed
+1251 -124

No files matched your search

@@ -198,7 +198,7 @@ class Tier
* Seeds guest/free_member with today's real defaults — only if
* missing by slug, so a human's later edits via the CLI/settings
* screen are never clobbered on a later plugins_loaded (same
* never-overwrite convention as Payments\HelcimConfig).
* never-overwrite convention as Payments\HelcimGatewayConfig).
*/
public static function seed_defaults(): void
{
@@ -6,8 +6,11 @@ defined('ABSPATH') || exit;
/**
* bsc_order_audit: one row per order line, written once at checkout
* (CheckoutRecheck::finalize_order()) and updated once on refund
* (record_refund()). Style mirrors Catalog\SupplierOffer exactly.
* (CheckoutRecheck::run_checks()) and updated once — either on refund
* (record_refund(), a post-capture reversal) or on void (record_void(),
* a pre-capture supplier-order failure — the far more common case under
* the preauth-then-capture flow). Style mirrors Catalog\SupplierOffer
* exactly.
*/
class AuditTrail
{
@@ -63,6 +66,9 @@ class AuditTrail
* A full refund (Appendix B: "the store loses only the processing
* fee") recovers nothing else — record that as the only real loss
* against the expectation this row already captured at checkout time.
* Only reachable now for a refund *after* a real capture (e.g. a
* manual admin refund post-fulfillment) — a failed supplier order
* before capture goes through record_void() instead, below.
*/
public static function record_refund(int $order_id): void
{
@@ -75,4 +81,22 @@ class AuditTrail
$order_id
));
}
/**
* Preauth-then-capture rework: a failed supplier order before capture
* reverses the hold (Order\SupplierOrderJob::fail_and_release())
* rather than refunding a charge that was never made — genuinely
* zero cost, not the processing-fee loss record_refund() models.
*/
public static function record_void(int $order_id): void
{
global $wpdb;
$wpdb->query($wpdb->prepare(
"UPDATE " . self::table() . "
SET processor_cost_actual = 0,
actual_contribution = 0
WHERE order_id = %d",
$order_id
));
}
}
@@ -2,14 +2,25 @@
namespace Bookstore\Core\Order;
use Bookstore\Core\Payments\FraudCheck;
use Bookstore\Core\Payments\CheckoutSession;
use Bookstore\Core\Payments\HelcimApiException;
use Bookstore\Core\Payments\HelcimClient;
use Bookstore\Core\Payments\HelcimGateway;
use Bookstore\Core\Pricing\PricingEngine;
defined('ABSPATH') || exit;
/**
* Appendix B: "Supplier stock, price, and margin are rechecked live,
* synchronously, immediately before the customer's card is charged."
* Appendix B (revised for the preauth-then-capture rework): "Supplier
* stock, price, and margin are rechecked live, synchronously,
* immediately before the customer's card is authorized (held)." The
* fraud check moved out of run_checks() and into
* Payments\HelcimGateway::process_payment() — it now runs *after* the
* preauth exists rather than before, per the new checkout -> preauth ->
* fraud review -> supplier order -> capture sequence. This class still
* owns price/margin only, and (new) reversing any preauth that's
* already been taken if that recheck fails here — see the "wrinkle"
* note on validate_order_before_payment() below.
*
* Two checkout implementations, two different hook sets — confirmed by
* reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -132,6 +143,13 @@ class CheckoutRecheck
* *supplier-submission* execution (SupplierOrderJob's own meta+lock
* guards — see that class), which is what the design doc's "duplicate
* webhook/job... do not double-order" requirement is actually about.
*
* New wrinkle from the preauth rework: by the time this hook fires,
* the customer has already completed a preauth via the Blocks
* checkout JS (Payments\HelcimGateway isn't invoked yet — that only
* happens after this hook passes). If the recheck fails here, that
* hold is real and live but process_payment() will never run to
* clean it up — so run_checks()'s failure path reverses it directly.
*/
public static function validate_order_before_payment(\WC_Order $order, \WP_Error $validation_errors): void
{
@@ -155,12 +173,12 @@ class CheckoutRecheck
/**
* Shared by both checkout implementations. Returns null on success
* (audit rows already written, fraud check already logged) or a
* customer-facing failure message (order already routed to the
* exception status) on failure. Audit rows are only ever inserted
* after every item AND the fraud check have passed — never partially,
* which matters because a failed attempt can be resubmitted against
* the same draft order under Blocks checkout.
* (audit rows already written) or a customer-facing failure message
* (order already routed to the exception status, any preauth already
* reversed) on failure. Audit rows are only ever inserted after every
* item has passed — never partially, which matters because a failed
* attempt can be resubmitted against the same draft order under
* Blocks checkout.
*/
private static function run_checks(\WC_Order $order): ?string
{
@@ -177,13 +195,14 @@ class CheckoutRecheck
$quoted_unit = (float) $item->get_total() / $quantity;
if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) {
self::reverse_pending_preauth();
self::route_to_exception($order, sprintf(
'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).',
$item->get_name(),
$quoted_unit,
$recheck->price ?? 0.0
));
return 'Sorry, part of your order is no longer available at its listed price. You have not been charged — please try again.';
return 'Sorry, part of your order is no longer available at its listed price. Any card hold has been released — please try again.';
}
$offer = $recheck->offer;
@@ -206,12 +225,6 @@ class CheckoutRecheck
];
}
$fraud = FraudCheck::run($order);
if (!$fraud['pass']) {
self::route_to_exception($order, 'Fraud check failed: ' . $fraud['reason']);
return 'Your order could not be processed and has been placed under review. You have not been charged.';
}
foreach ($pending_audit_rows as $row) {
AuditTrail::insert(...$row);
}
@@ -219,6 +232,28 @@ class CheckoutRecheck
return null;
}
/**
* Fraud check already ran (Payments\HelcimGateway::process_payment(),
* after the preauth) by the time price/margin ever gets a chance to
* fail here — this only fires when the preauth exists but the recheck
* itself fails first. Best-effort: nothing was ever charged either
* way, and an unreversed hold just expires on its own within Helcim's
* 7-day window if this call fails.
*/
private static function reverse_pending_preauth(): void
{
$pending = CheckoutSession::pending_preauth();
if (!$pending) {
return;
}
CheckoutSession::clear_pending_preauth();
try {
HelcimClient::reverse($pending['preauth_transaction_id'], HelcimGateway::client_ip());
} catch (HelcimApiException $e) {
error_log('bsc_helcim: reverse failed after a failed price/margin recheck: ' . $e->getMessage());
}
}
private static function route_to_exception(\WC_Order $order, string $reason): void
{
$order->update_status(OrderStatuses::EXCEPTION, $reason);
@@ -5,18 +5,19 @@ namespace Bookstore\Core\Order;
defined('ABSPATH') || exit;
/**
* Design doc §04/Appendix B state machine: Pending -> Supplier Verify ->
* Margin Verify -> Fraud Check -> Charged -> Supplier Ordered -> Shipped
* -> Complete. Pending/Charged/Complete map onto WooCommerce's own
* pending/processing/completed; Supplier Verify, Margin Verify, and Fraud
* Check all complete within one request (CheckoutRecheck) and are logged
* as order notes rather than full status transitions — a transition per
* millisecond-scale sub-step would register three more email/report
* buckets for no observable benefit. What *does* need a real, persisted,
* filterable status is "every failure path must end in a visible review
* state" — hence exactly one new failure status (bsc-exception) plus the
* two remaining forward-progress steps the state machine names
* (bsc-supplier-ordered, bsc-shipped).
* Design doc §04/Appendix B state machine, revised again for the
* preauth-then-capture rework: Pending -> Margin Verify -> Authorized
* (Held) -> Fraud Check -> Supplier Ordered -> Charged -> Shipped ->
* Complete. Pending/Charged/Complete map onto WooCommerce's own
* pending/processing/completed. Margin Verify still completes within one
* request (CheckoutRecheck) and is logged as an order note rather than a
* full status transition. Authorized IS a real persisted status now —
* unlike the single-request sub-steps, it can legitimately sit for a
* while (fraud review, the supplier-order Action Scheduler job) with
* real money on hold, so it needs to be visible/filterable in wp-admin,
* not just logged. Same reasoning already covered bsc-exception
* ("every failure path must end in a visible review state") and the two
* forward-progress steps (bsc-ordered, bsc-shipped).
*
* register_post_status()/the wc_order_statuses filter is the standard
* WooCommerce recipe for adding a custom order status and is unaffected
@@ -33,6 +34,11 @@ class OrderStatuses
// the nonsensical 'bsc-supplier-orde'. 'wc-bsc-ordered' is 14 chars.
public const SUPPLIER_ORDERED = 'bsc-ordered';
public const SHIPPED = 'bsc-shipped';
// Reintroduced by the preauth-then-capture rework (design doc
// Appendix B, revised again) — sits between Pending and (native)
// Processing: the card is held, not yet charged. 'wc-bsc-authorized'
// is 17 chars, fits the same VARCHAR(20) constraint above.
public const AUTHORIZED = 'bsc-authorized';
public static function register(): void
{
@@ -42,6 +48,15 @@ class OrderStatuses
public static function register_post_statuses(): void
{
register_post_status('wc-' . self::AUTHORIZED, [
'label' => 'Authorized (Held)',
'public' => false,
'exclude_from_search' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => self::noop_label_count('Authorized (Held)'),
]);
register_post_status('wc-' . self::EXCEPTION, [
'label' => 'Needs Review',
'public' => false,
@@ -75,6 +90,9 @@ class OrderStatuses
$with_new = [];
foreach ($statuses as $key => $label) {
$with_new[$key] = $label;
if ($key === 'wc-pending') {
$with_new['wc-' . self::AUTHORIZED] = 'Authorized (Held)';
}
if ($key === 'wc-processing') {
$with_new['wc-' . self::SUPPLIER_ORDERED] = 'Supplier Ordered';
$with_new['wc-' . self::SHIPPED] = 'Shipped';
@@ -3,33 +3,47 @@
namespace Bookstore\Core\Order;
use Bookstore\Core\Catalog\SupplierOffer;
use Bookstore\Core\Payments\HelcimApiException;
use Bookstore\Core\Payments\HelcimClient;
use Bookstore\Core\Payments\HelcimGateway;
defined('ABSPATH') || exit;
/**
* Design doc §04/§06: "Supplier Ordered" — submitting the charged order to
* the actual supplier. Routed through Action Scheduler now, even against
* the synthetic gutenberg_test supplier which has no real API to be
* Design doc §04/§06: "Supplier Ordered" — submitting the order to the
* actual supplier. Routed through Action Scheduler now, even against the
* synthetic gutenberg_test supplier which has no real API to be
* slow/flaky against, so Week 4's BooksRun/Ingram integration only swaps
* this class's run() body for a real HTTP call — the scheduling, locking,
* status transition, and exception-routing plumbing is already correct
* and untouched. Also where the plan doc's "BooksRun/API timeout -> retry
* safely via queue; do not double-order" idempotency requirement actually
* lives, ahead of Week 4 needing it for real.
*
* Preauth-then-capture rework: this class now runs BEFORE the card is
* actually charged, not after. It triggers on the order reaching
* Order\OrderStatuses::AUTHORIZED (preauth held, fraud check already
* passed — Payments\HelcimGateway) rather than woocommerce_payment_complete.
* A successful supplier submission captures the held funds for the first
* time (payment_complete() only fires here now, at capture); a failed one
* reverses the hold instead of refunding a charge that was never made —
* the whole point of this rework, replacing Appendix B's accepted
* "refund, lose the processing fee" risk with a genuinely zero-cost void.
*/
class SupplierOrderJob
{
public static function register(): void
{
add_action('woocommerce_payment_complete', [self::class, 'schedule_for_order']);
add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']);
add_action('bsc_submit_supplier_order', [self::class, 'run']);
}
/**
* woocommerce_payment_complete is documented to sometimes fire more
* than once for the same order depending on gateway/flow — this guard
* (checked again, more strongly, inside run() itself) is what stops a
* duplicate firing from scheduling the submission twice.
* WooCommerce's per-status transition hooks can fire more than once
* for the same order (e.g. a status re-saved without actually
* changing) — this guard (checked again, more strongly, inside run()
* itself) is what stops a duplicate firing from scheduling the
* submission twice.
*/
public static function schedule_for_order(int $order_id): void
{
@@ -72,7 +86,7 @@ class SupplierOrderJob
if (!$offer) {
$order->update_meta_data('_bsc_supplier_order_status', 'failed');
$order->save();
self::fail_and_refund(
self::fail_and_release(
$order,
"Pinned supplier offer #{$audit_row->offer_id} is no longer sellable at fulfillment time."
);
@@ -83,44 +97,66 @@ class SupplierOrderJob
$order->add_order_note('Supplier order submitted (synthetic: gutenberg_test).');
$order->update_meta_data('_bsc_supplier_order_status', 'submitted');
$order->save();
$order->update_status(OrderStatuses::SUPPLIER_ORDERED);
self::capture_and_complete($order);
} finally {
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key));
}
}
/**
* Appendix B residual risk, made concrete: "a charge that later needs
* to be undone... becomes a refund instead of a void. The customer is
* made whole in full; the store loses only the processing fee."
* refund_payment=true is what makes wc_create_refund() actually invoke
* the gateway's process_refund() rather than just recording a line
* item.
* The supplier order just succeeded — this is the first point the
* card is actually charged. Captures the preauth held since checkout,
* then calls WC's own payment_complete() (which now fires
* woocommerce_payment_complete for real, restoring that hook's
* original meaning) before moving to Supplier Ordered.
*
* wc_create_refund() returns a WP_Error — not false, not a thrown
* exception — when the active gateway doesn't support automatic
* refunds (confirmed directly: WooCommerce's own stock Cheque/BACS/COD
* gateways all lack process_refund(), and this is exactly the gateway
* used to test this path without real Helcim credentials yet). Left
* unchecked, the refund silently never happens with no trace anywhere
* — the order still reaches bsc-exception, but "every failure path
* must end in a visible review state" needs the refund's own outcome
* to be visible too, not just the parent order state.
* Capture failing here (card expired/declined during the hold window
* — rare, but real) is its own distinct failure mode from a supplier-
* side problem: the supplier submission already (synthetically, for
* now) succeeded, so this can't cleanly fall back to fail_and_release()
* the way an unsellable offer can. Routes to exception with an
* explicit "needs manual follow-up" note instead — the same manual-
* fallback philosophy Appendix B already accepted for one-off
* unfulfillable orders.
*/
private static function fail_and_refund(\WC_Order $order, string $reason): void
private static function capture_and_complete(\WC_Order $order): void
{
$preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id');
$amount = (float) $order->get_total();
try {
$capture = HelcimClient::capture($preauth_id, $amount, HelcimGateway::client_ip());
} catch (HelcimApiException $e) {
$order->update_status(
OrderStatuses::EXCEPTION,
"Supplier order succeeded but capture failed ({$e->getMessage()}) — needs manual follow-up, do not resubmit to the supplier."
);
return;
}
$order->payment_complete((string) ($capture['transactionId'] ?? $preauth_id));
$order->update_status(OrderStatuses::SUPPLIER_ORDERED);
}
/**
* Appendix B's residual risk ("a charge that later needs to be
* undone becomes a refund... the store loses only the processing
* fee") is exactly what this rework was for: nothing has been
* charged yet at this point, so a failed supplier order releases the
* hold instead — genuinely zero cost, not "only the processing fee."
*/
private static function fail_and_release(\WC_Order $order, string $reason): void
{
$order->update_status(OrderStatuses::EXCEPTION, $reason);
$refund = wc_create_refund([
'order_id' => $order->get_id(),
'amount' => (float) $order->get_total(),
'reason' => $reason,
'refund_payment' => true,
]);
if (is_wp_error($refund)) {
$preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id');
try {
HelcimClient::reverse($preauth_id, HelcimGateway::client_ip());
AuditTrail::record_void($order->get_id());
} catch (HelcimApiException $e) {
$order->add_order_note(
'Automatic refund could not be issued (' . $refund->get_error_message() . ') — needs a manual refund.'
'Could not release the card hold (' . $e->getMessage() . ') — it will expire on its own within 7 days; no manual refund needed.'
);
}
}
@@ -0,0 +1,152 @@
<?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']);
}
}
@@ -0,0 +1,51 @@
<?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());
}
}
@@ -11,9 +11,12 @@ defined('ABSPATH') || exit;
* the extension point an optional paid plugin (Shop Defender) plugs into
* later via add_filter('bsc_fraud_check_result', ...). The step still
* runs and is logged on every order, unconditionally, so "Fraud Check" is
* a real, auditable stage in the state machine rather than an absent one
* — see Order\CheckoutRecheck::finalize_order(), which calls this right
* before an order is allowed to proceed to the payment gateway.
* a real, auditable stage in the state machine rather than an absent one.
* Called from HelcimGateway::process_payment(), right after the card is
* preauthorized (held) but before the order is allowed to proceed to
* Authorized status — moved here from Order\CheckoutRecheck by the
* preauth-then-capture rework: fraud review now runs after the hold
* exists, not before, per design doc Appendix B's revised sequence.
*/
class FraudCheck
{
@@ -0,0 +1,9 @@
<?php
namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
class HelcimApiException extends \RuntimeException
{
}
@@ -0,0 +1,64 @@
<?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'] ?? '',
];
}
}
@@ -0,0 +1,123 @@
<?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;
}
}
@@ -0,0 +1,127 @@
<?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';
}
}
@@ -5,30 +5,36 @@ namespace Bookstore\Core\Payments;
defined('ABSPATH') || exit;
/**
* Seeds the Helcim gateway's WooCommerce settings from the environment.
* Gateway id `helcimjs`, settings option `woocommerce_helcimjs_settings`,
* field keys `enabled`/`title`/`description`/`environment`/
* `sandbox_api_token`/`live_api_token`/`enable_saved_cards` — confirmed by
* installing helcim-commerce-for-woocommerce v5.0.3 in dev and reading its
* includes/Gateway.php + includes/Options.php directly, not guessed. No
* separate "account ID" field exists on this plugin — HELCIM_ACCOUNT_ID
* (still scaffolded in .env/docker-compose.yml) is unused here.
* Replaces Payments\HelcimConfig (which seeded settings for the
* off-the-shelf helcim-commerce-for-woocommerce plugin — removed, see
* design doc Appendix B's revised decision: that plugin only supports
* immediate-charge "Purchase" transactions, not the preauth/capture flow
* this now uses, so a custom gateway (Payments\HelcimGateway) replaces
* it rather than extending it). Gateway id `bsc_helcim`, settings option
* `woocommerce_bsc_helcim_settings` — our own, not the old plugin's.
*
* _FILE env-var reading mirrors Integration\HardcoverAdapter::token()
* exactly — same reasoning: a plain ${VAR} is visible via `docker
* exactly, same reasoning: a plain ${VAR} is visible via `docker
* inspect`/`/proc/<pid>/environ`, a 644 file inside a 700 secrets/ dir
* isn't.
*/
class HelcimConfig
class HelcimGatewayConfig
{
private const OPTION_NAME = 'woocommerce_helcimjs_settings';
public const OPTION_NAME = 'woocommerce_bsc_helcim_settings';
/**
* False today (no HELCIM_API_TOKEN in any .env yet) — this is
* exactly the condition Payments\HelcimClient checks to decide
* whether to talk to the real Helcim API or return fake-mode
* responses. Flips to true, with zero code changes anywhere else,
* the moment a real sandbox token is added.
*/
public static function is_configured(): bool
{
return self::token() !== null;
}
private static function token(): ?string
public static function token(): ?string
{
$file = getenv('HELCIM_API_TOKEN_FILE');
if (!$file || !is_readable($file)) {
@@ -40,18 +46,13 @@ class HelcimConfig
/**
* Fills in only what's still unset — never overwrites a value a human
* already changed by hand in wp-admin (e.g. a custom title/description,
* or a deliberate switch to 'live'). Only ever seeds the SANDBOX
* already changed by hand in wp-admin. Only ever seeds the sandbox
* token: this repo has no production Helcim credentials, and going
* live is a deliberate separate decision a human makes on the
* settings screen, not something an env var should flip silently.
*/
public static function ensure_configured_from_env(): void
{
if (!class_exists(\Helcim\Gateway::class)) {
return; // plugin not installed/active
}
$token = self::token();
if ($token === null) {
return;
@@ -67,7 +68,6 @@ class HelcimConfig
'title' => 'Credit Card',
'description' => '',
'environment' => 'sandbox',
'enable_saved_cards' => 'no',
];
if (empty($settings['sandbox_api_token'])) {