Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fe4e4c5
Add SessionVerifier to FraudProtectionController and implement sessio…
leonardola Mar 9, 2026
01c6a58
Add OrderEventsTracker to FraudProtectionController and initialize in…
leonardola Mar 9, 2026
a98602f
Enhance OrderEventsTracker to support fraud protection reporting
leonardola Mar 11, 2026
b93c1bc
Refactor ApiClient to conditionally handle payload structure for repo…
leonardola Mar 11, 2026
4fc2993
Removed refund hook as payment gateways will need to trigger the repo…
leonardola Mar 11, 2026
62945e7
Update OrderEventsTracker to expose `woocommerce_fraud_protection_rep…
leonardola Mar 11, 2026
dddcb2e
Enhance on_fraud_protection_report method to validate status input
leonardola Mar 11, 2026
fd78252
Refactor ApiClient to prevent payload from overriding session_id and …
leonardola Mar 16, 2026
0eb3fe9
Enhance ApiClient so make_request does not need to care about the dat…
leonardola Mar 16, 2026
249315e
Add logging for missing session ID in OrderEventsTracker
leonardola Mar 16, 2026
a8fe90e
Now the report strings have their own constants
leonardola Mar 16, 2026
cf6e707
Sanitized the $notes data
leonardola Mar 16, 2026
384f4e8
Added test for "good" reports
leonardola Mar 16, 2026
740a379
Added test for when `wc_get_order` returns false
leonardola Mar 16, 2026
3c66293
Refactor fraud protection reporting mechanism
leonardola Mar 17, 2026
2e7f2f6
Enhance fraud protection reporting with event source tracking
leonardola Mar 17, 2026
6277269
Make feature_is_enabled() static and guard compat layer registration
luizreis Mar 12, 2026
5521d65
Added feature_enabled check to prevent reports when it's disabled
leonardola Mar 19, 2026
8271533
Added empty line
leonardola Mar 19, 2026
371f9f5
Removed private_key override protection as it's not going to be used …
leonardola Mar 19, 2026
cf956a9
Moved the blog_id check into the make_request function so the check r…
leonardola Mar 19, 2026
0cff398
Removed empty register method
leonardola Mar 19, 2026
49114b4
Refactor error handling in ApiClient and remove unused OrderEventsTra…
leonardola Mar 19, 2026
a4e1841
Removed unecessary assertions after stop sending the blog_id to the api
leonardola Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 64 additions & 11 deletions src/ApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,52 @@ class ApiClient {
self::DECISION_BLOCK,
);

/**
* Report status: good outcome.
*/
public const REPORT_STATUS_GOOD = 'good';

/**
* Report status: bad outcome.
*/
public const REPORT_STATUS_BAD = 'bad';

/**
* Valid report status values.
*
* @var array<string>
*/
public const VALID_REPORT_STATUSES = array(
self::REPORT_STATUS_GOOD,
self::REPORT_STATUS_BAD,
);

/**
* Report source: chargeback event.
*/
public const REPORT_SOURCE_CHARGEBACK = 'chargeback';

/**
* Report source: manual review outcome.
*/
public const REPORT_SOURCE_MANUAL_REVIEW = 'manual_review';

/**
* Report source: API-driven event.
*/
public const REPORT_SOURCE_API = 'api';

/**
* Valid report source values.
*
* @var array<string>
*/
public const VALID_REPORT_SOURCES = array(
self::REPORT_SOURCE_CHARGEBACK,
self::REPORT_SOURCE_MANUAL_REVIEW,
self::REPORT_SOURCE_API,
);

/**
* Verify a session with the Blackbox API and get a fraud decision.
*
Expand All @@ -95,7 +141,16 @@ public function verify( string $session_id, array $payload ): string {
)
);

$response = $this->make_request( 'POST', self::VERIFY_ENDPOINT, $session_id, $payload );
$payload = array(
'context' => $payload,
);

$response = $this->make_request(
'POST',
self::VERIFY_ENDPOINT,
$session_id,
$payload
);

return $this->process_decision_response( $response, $payload );
}
Expand Down Expand Up @@ -223,21 +278,19 @@ private function make_request( string $method, string $path, string $session_id,
);
}

$blog_id = $this->get_blog_id();
if ( ! $blog_id ) {
if ( ! $this->get_blog_id() ) {
return new \WP_Error(
'no_blog_id',
'Jetpack blog ID not found. Is the site connected to WordPress.com?'
'blog_id_not_found',
'Jetpack blog ID not found'
);
}

$payload['blog_id'] = $blog_id;

$body = \wp_json_encode(
array(
'session_id' => $session_id,
'private_key' => '', // Woo will not use private keys for now.
'context' => $payload,
array_merge(
$payload,
array(
'session_id' => $session_id,
)
)
);

Expand Down
140 changes: 140 additions & 0 deletions src/Compat/SubscriptionsChangePaymentCompat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
/**
* SubscriptionsChangePaymentCompat class file.
*/

declare( strict_types=1 );

namespace Automattic\WooCommerce\FraudProtection\Compat;

use Automattic\WooCommerce\FraudProtection\ApiClient;
use Automattic\WooCommerce\FraudProtection\BlockedSessionNotice;
use Automattic\WooCommerce\FraudProtection\ClassicFormDataExtractionTrait;
use Automattic\WooCommerce\FraudProtection\FraudProtectionController;
use Automattic\WooCommerce\FraudProtection\SessionVerifier;

defined( 'ABSPATH' ) || exit;

/**
* Integrates Blackbox fraud protection into the WooCommerce Subscriptions
* "Change Payment Method" flow.
*
* WooCommerce Subscriptions hijacks the pay-for-order page with its own
* request handler, so WC Core's `woocommerce_before_pay_action` never fires
* and the PayForOrderProtector server-side hook is bypassed. This compat
* layer hooks the Subscriptions action that fires after nonce verification
* to verify the session and block on BLOCK decisions.
*
* The JS side is already handled: PayForOrderProtector enqueues pay-for-order.js
* on `is_checkout_pay_page()`, which matches the change-payment page. The script
* gates the `#order_review` form submission to inject the Blackbox session ID.
*
* On BLOCK, the request is stopped entirely via redirect + exit, preventing both
* `update_payment_method()` and `process_payment()` from running.
*
* Fail-open: SessionVerifier handles all internal errors and returns ALLOW.
*
* Lives in Compat/ rather than as a top-level Protector because it targets
* a third-party extension hook and is only relevant when Subscriptions is active.
*
* @internal
*/
class SubscriptionsChangePaymentCompat {

use ClassicFormDataExtractionTrait;

/**
* Source identifier for verify requests from this compat layer.
*/
private const SOURCE = 'subscriptions_change_payment_method';

/**
* Session verifier instance.
*
* @var SessionVerifier
*/
private SessionVerifier $session_verifier;

/**
* Blocked session notice instance.
*
* @var BlockedSessionNotice
*/
private BlockedSessionNotice $blocked_session_notice;

/**
* Initialize with dependencies.
*
* @internal
*
* @param SessionVerifier $session_verifier The session verifier instance.
* @param BlockedSessionNotice $blocked_session_notice The blocked session notice instance.
*/
final public function init(
SessionVerifier $session_verifier,
BlockedSessionNotice $blocked_session_notice
): void {
$this->session_verifier = $session_verifier;
$this->blocked_session_notice = $blocked_session_notice;
}

/**
* Register hooks for change-payment-method fraud protection.
*
* The hook only fires when WooCommerce Subscriptions is active and
* a customer submits the change-payment-method form.
*
* @return void
*/
public function register(): void {
if ( ! FraudProtectionController::feature_is_enabled() ) {
return;
}

add_action( 'woocommerce_subscription_change_payment_method_via_pay_shortcode', array( $this, 'verify_and_block' ) );
}

/**
* Verify the session with Blackbox and block if needed.
*
* Called during the `woocommerce_subscription_change_payment_method_via_pay_shortcode`
* action, which fires AFTER nonce verification but BEFORE `update_payment_method()`
* and `process_payment()`.
*
* On BLOCK, stops the request entirely via redirect + exit. This prevents the
* Subscriptions handler from reaching `update_payment_method()` (which saves to DB
* and triggers gateway cancellation) and `process_payment()`.
*
* Fail-open: SessionVerifier handles all internal errors and returns ALLOW.
*
* @internal
*
* @param \WC_Order $subscription The subscription being updated (WC_Subscription extends WC_Order).
* @return void
*/
public function verify_and_block( \WC_Order $subscription ): void {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by Subscriptions handler.
$request_data = $this->build_request_data( $_POST );

$decision = $this->session_verifier->verify_session(
$this->get_blackbox_session_id(),
self::SOURCE,
$subscription->get_id(),
$request_data
);

if ( ApiClient::DECISION_BLOCK === $decision ) {
$message = $this->blocked_session_notice->get_message_html( 'generic' );
if ( ! wc_has_notice( $message, 'error' ) ) {
wc_add_notice( $message, 'error' );
}

// Stop the request entirely — the Subscriptions handler would otherwise
// proceed to update_payment_method() and process_payment().
// Redirect to the view-subscription page rather than back to the
// change-payment form, which would be unusable in a blocked state.
wp_safe_redirect( $subscription->get_view_order_url() );
exit;
}
}
}
15 changes: 13 additions & 2 deletions src/FraudProtectionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ class FraudProtectionController /* implements RegisterHooksInterface */ {
*/
private PayForOrderProtector $pay_for_order_protector;

/**
* Session verifier instance.
*
* @var SessionVerifier
*/
private SessionVerifier $session_verifier;

/**
* Session blocking handler instance.
*
Expand Down Expand Up @@ -115,6 +122,7 @@ public function register(): void {
* @param CheckoutEventTracker $checkout_event_tracker The instance of CheckoutEventTracker to use.
* @param PaymentMethodEventTracker $payment_method_event_tracker The instance of PaymentMethodEventTracker to use.
* @param SessionBlockingHandler $session_blocking_handler The instance of SessionBlockingHandler to use.
* @param SessionVerifier $session_verifier The instance of SessionVerifier to use.
* @param BlocksCheckoutProtector $blocks_checkout_protector The instance of BlocksCheckoutProtector to use.
* @param ShortcodeCheckoutProtector $shortcode_checkout_protector The instance of ShortcodeCheckoutProtector to use.
* @param AddPaymentMethodProtector $add_payment_method_protector The instance of AddPaymentMethodProtector to use.
Expand All @@ -128,6 +136,7 @@ final public function init(
CheckoutEventTracker $checkout_event_tracker,
PaymentMethodEventTracker $payment_method_event_tracker,
SessionBlockingHandler $session_blocking_handler,
SessionVerifier $session_verifier,
BlocksCheckoutProtector $blocks_checkout_protector,
ShortcodeCheckoutProtector $shortcode_checkout_protector,
AddPaymentMethodProtector $add_payment_method_protector,
Expand All @@ -140,6 +149,7 @@ final public function init(
$this->checkout_event_tracker = $checkout_event_tracker;
$this->payment_method_event_tracker = $payment_method_event_tracker;
$this->session_blocking_handler = $session_blocking_handler;
$this->session_verifier = $session_verifier;
$this->blocks_checkout_protector = $blocks_checkout_protector;
$this->shortcode_checkout_protector = $shortcode_checkout_protector;
$this->add_payment_method_protector = $add_payment_method_protector;
Expand All @@ -154,12 +164,13 @@ final public function init(
*/
public function on_init(): void {
// Bail if the feature is not enabled.
if ( ! $this->feature_is_enabled() ) {
if ( ! self::feature_is_enabled() ) {
return;
}

$this->blocked_session_notice->register();
$this->blackbox_script_handler->register();
$this->session_verifier->register();
$this->blocks_checkout_protector->register();
$this->shortcode_checkout_protector->register();
$this->add_payment_method_protector->register();
Expand All @@ -179,7 +190,7 @@ public function on_init(): void {
*
* @return bool True if enabled, false if not enabled or init hasn't run yet.
*/
public function feature_is_enabled(): bool {
public static function feature_is_enabled(): bool {
// Fail-open: don't block if init hasn't run yet to avoid FeaturesController translation notices.
if ( ! did_action( 'init' ) ) {
return false;
Expand Down
Loading
Loading