Skip to content

Commit d86f887

Browse files
authored
Fraud Protection: Add report flow with session ID persistence and order event tracking (#34)
1 parent d7c70de commit d86f887

11 files changed

+781
-35
lines changed

src/ApiClient.php

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,52 @@ class ApiClient {
7575
self::DECISION_BLOCK,
7676
);
7777

78+
/**
79+
* Report status: good outcome.
80+
*/
81+
public const REPORT_STATUS_GOOD = 'good';
82+
83+
/**
84+
* Report status: bad outcome.
85+
*/
86+
public const REPORT_STATUS_BAD = 'bad';
87+
88+
/**
89+
* Valid report status values.
90+
*
91+
* @var array<string>
92+
*/
93+
public const VALID_REPORT_STATUSES = array(
94+
self::REPORT_STATUS_GOOD,
95+
self::REPORT_STATUS_BAD,
96+
);
97+
98+
/**
99+
* Report source: chargeback event.
100+
*/
101+
public const REPORT_SOURCE_CHARGEBACK = 'chargeback';
102+
103+
/**
104+
* Report source: manual review outcome.
105+
*/
106+
public const REPORT_SOURCE_MANUAL_REVIEW = 'manual_review';
107+
108+
/**
109+
* Report source: API-driven event.
110+
*/
111+
public const REPORT_SOURCE_API = 'api';
112+
113+
/**
114+
* Valid report source values.
115+
*
116+
* @var array<string>
117+
*/
118+
public const VALID_REPORT_SOURCES = array(
119+
self::REPORT_SOURCE_CHARGEBACK,
120+
self::REPORT_SOURCE_MANUAL_REVIEW,
121+
self::REPORT_SOURCE_API,
122+
);
123+
78124
/**
79125
* Verify a session with the Blackbox API and get a fraud decision.
80126
*
@@ -95,7 +141,16 @@ public function verify( string $session_id, array $payload ): string {
95141
)
96142
);
97143

98-
$response = $this->make_request( 'POST', self::VERIFY_ENDPOINT, $session_id, $payload );
144+
$payload = array(
145+
'context' => $payload,
146+
);
147+
148+
$response = $this->make_request(
149+
'POST',
150+
self::VERIFY_ENDPOINT,
151+
$session_id,
152+
$payload
153+
);
99154

100155
return $this->process_decision_response( $response, $payload );
101156
}
@@ -223,21 +278,19 @@ private function make_request( string $method, string $path, string $session_id,
223278
);
224279
}
225280

226-
$blog_id = $this->get_blog_id();
227-
if ( ! $blog_id ) {
281+
if ( ! $this->get_blog_id() ) {
228282
return new \WP_Error(
229-
'no_blog_id',
230-
'Jetpack blog ID not found. Is the site connected to WordPress.com?'
283+
'blog_id_not_found',
284+
'Jetpack blog ID not found'
231285
);
232286
}
233287

234-
$payload['blog_id'] = $blog_id;
235-
236288
$body = \wp_json_encode(
237-
array(
238-
'session_id' => $session_id,
239-
'private_key' => '', // Woo will not use private keys for now.
240-
'context' => $payload,
289+
array_merge(
290+
$payload,
291+
array(
292+
'session_id' => $session_id,
293+
)
241294
)
242295
);
243296

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
/**
3+
* SubscriptionsChangePaymentCompat class file.
4+
*/
5+
6+
declare( strict_types=1 );
7+
8+
namespace Automattic\WooCommerce\FraudProtection\Compat;
9+
10+
use Automattic\WooCommerce\FraudProtection\ApiClient;
11+
use Automattic\WooCommerce\FraudProtection\BlockedSessionNotice;
12+
use Automattic\WooCommerce\FraudProtection\ClassicFormDataExtractionTrait;
13+
use Automattic\WooCommerce\FraudProtection\FraudProtectionController;
14+
use Automattic\WooCommerce\FraudProtection\SessionVerifier;
15+
16+
defined( 'ABSPATH' ) || exit;
17+
18+
/**
19+
* Integrates Blackbox fraud protection into the WooCommerce Subscriptions
20+
* "Change Payment Method" flow.
21+
*
22+
* WooCommerce Subscriptions hijacks the pay-for-order page with its own
23+
* request handler, so WC Core's `woocommerce_before_pay_action` never fires
24+
* and the PayForOrderProtector server-side hook is bypassed. This compat
25+
* layer hooks the Subscriptions action that fires after nonce verification
26+
* to verify the session and block on BLOCK decisions.
27+
*
28+
* The JS side is already handled: PayForOrderProtector enqueues pay-for-order.js
29+
* on `is_checkout_pay_page()`, which matches the change-payment page. The script
30+
* gates the `#order_review` form submission to inject the Blackbox session ID.
31+
*
32+
* On BLOCK, the request is stopped entirely via redirect + exit, preventing both
33+
* `update_payment_method()` and `process_payment()` from running.
34+
*
35+
* Fail-open: SessionVerifier handles all internal errors and returns ALLOW.
36+
*
37+
* Lives in Compat/ rather than as a top-level Protector because it targets
38+
* a third-party extension hook and is only relevant when Subscriptions is active.
39+
*
40+
* @internal
41+
*/
42+
class SubscriptionsChangePaymentCompat {
43+
44+
use ClassicFormDataExtractionTrait;
45+
46+
/**
47+
* Source identifier for verify requests from this compat layer.
48+
*/
49+
private const SOURCE = 'subscriptions_change_payment_method';
50+
51+
/**
52+
* Session verifier instance.
53+
*
54+
* @var SessionVerifier
55+
*/
56+
private SessionVerifier $session_verifier;
57+
58+
/**
59+
* Blocked session notice instance.
60+
*
61+
* @var BlockedSessionNotice
62+
*/
63+
private BlockedSessionNotice $blocked_session_notice;
64+
65+
/**
66+
* Initialize with dependencies.
67+
*
68+
* @internal
69+
*
70+
* @param SessionVerifier $session_verifier The session verifier instance.
71+
* @param BlockedSessionNotice $blocked_session_notice The blocked session notice instance.
72+
*/
73+
final public function init(
74+
SessionVerifier $session_verifier,
75+
BlockedSessionNotice $blocked_session_notice
76+
): void {
77+
$this->session_verifier = $session_verifier;
78+
$this->blocked_session_notice = $blocked_session_notice;
79+
}
80+
81+
/**
82+
* Register hooks for change-payment-method fraud protection.
83+
*
84+
* The hook only fires when WooCommerce Subscriptions is active and
85+
* a customer submits the change-payment-method form.
86+
*
87+
* @return void
88+
*/
89+
public function register(): void {
90+
if ( ! FraudProtectionController::feature_is_enabled() ) {
91+
return;
92+
}
93+
94+
add_action( 'woocommerce_subscription_change_payment_method_via_pay_shortcode', array( $this, 'verify_and_block' ) );
95+
}
96+
97+
/**
98+
* Verify the session with Blackbox and block if needed.
99+
*
100+
* Called during the `woocommerce_subscription_change_payment_method_via_pay_shortcode`
101+
* action, which fires AFTER nonce verification but BEFORE `update_payment_method()`
102+
* and `process_payment()`.
103+
*
104+
* On BLOCK, stops the request entirely via redirect + exit. This prevents the
105+
* Subscriptions handler from reaching `update_payment_method()` (which saves to DB
106+
* and triggers gateway cancellation) and `process_payment()`.
107+
*
108+
* Fail-open: SessionVerifier handles all internal errors and returns ALLOW.
109+
*
110+
* @internal
111+
*
112+
* @param \WC_Order $subscription The subscription being updated (WC_Subscription extends WC_Order).
113+
* @return void
114+
*/
115+
public function verify_and_block( \WC_Order $subscription ): void {
116+
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by Subscriptions handler.
117+
$request_data = $this->build_request_data( $_POST );
118+
119+
$decision = $this->session_verifier->verify_session(
120+
$this->get_blackbox_session_id(),
121+
self::SOURCE,
122+
$subscription->get_id(),
123+
$request_data
124+
);
125+
126+
if ( ApiClient::DECISION_BLOCK === $decision ) {
127+
$message = $this->blocked_session_notice->get_message_html( 'generic' );
128+
if ( ! wc_has_notice( $message, 'error' ) ) {
129+
wc_add_notice( $message, 'error' );
130+
}
131+
132+
// Stop the request entirely — the Subscriptions handler would otherwise
133+
// proceed to update_payment_method() and process_payment().
134+
// Redirect to the view-subscription page rather than back to the
135+
// change-payment form, which would be unusable in a blocked state.
136+
wp_safe_redirect( $subscription->get_view_order_url() );
137+
exit;
138+
}
139+
}
140+
}

src/FraudProtectionController.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ class FraudProtectionController /* implements RegisterHooksInterface */ {
8383
*/
8484
private PayForOrderProtector $pay_for_order_protector;
8585

86+
/**
87+
* Session verifier instance.
88+
*
89+
* @var SessionVerifier
90+
*/
91+
private SessionVerifier $session_verifier;
92+
8693
/**
8794
* Session blocking handler instance.
8895
*
@@ -115,6 +122,7 @@ public function register(): void {
115122
* @param CheckoutEventTracker $checkout_event_tracker The instance of CheckoutEventTracker to use.
116123
* @param PaymentMethodEventTracker $payment_method_event_tracker The instance of PaymentMethodEventTracker to use.
117124
* @param SessionBlockingHandler $session_blocking_handler The instance of SessionBlockingHandler to use.
125+
* @param SessionVerifier $session_verifier The instance of SessionVerifier to use.
118126
* @param BlocksCheckoutProtector $blocks_checkout_protector The instance of BlocksCheckoutProtector to use.
119127
* @param ShortcodeCheckoutProtector $shortcode_checkout_protector The instance of ShortcodeCheckoutProtector to use.
120128
* @param AddPaymentMethodProtector $add_payment_method_protector The instance of AddPaymentMethodProtector to use.
@@ -128,6 +136,7 @@ final public function init(
128136
CheckoutEventTracker $checkout_event_tracker,
129137
PaymentMethodEventTracker $payment_method_event_tracker,
130138
SessionBlockingHandler $session_blocking_handler,
139+
SessionVerifier $session_verifier,
131140
BlocksCheckoutProtector $blocks_checkout_protector,
132141
ShortcodeCheckoutProtector $shortcode_checkout_protector,
133142
AddPaymentMethodProtector $add_payment_method_protector,
@@ -140,6 +149,7 @@ final public function init(
140149
$this->checkout_event_tracker = $checkout_event_tracker;
141150
$this->payment_method_event_tracker = $payment_method_event_tracker;
142151
$this->session_blocking_handler = $session_blocking_handler;
152+
$this->session_verifier = $session_verifier;
143153
$this->blocks_checkout_protector = $blocks_checkout_protector;
144154
$this->shortcode_checkout_protector = $shortcode_checkout_protector;
145155
$this->add_payment_method_protector = $add_payment_method_protector;
@@ -154,12 +164,13 @@ final public function init(
154164
*/
155165
public function on_init(): void {
156166
// Bail if the feature is not enabled.
157-
if ( ! $this->feature_is_enabled() ) {
167+
if ( ! self::feature_is_enabled() ) {
158168
return;
159169
}
160170

161171
$this->blocked_session_notice->register();
162172
$this->blackbox_script_handler->register();
173+
$this->session_verifier->register();
163174
$this->blocks_checkout_protector->register();
164175
$this->shortcode_checkout_protector->register();
165176
$this->add_payment_method_protector->register();
@@ -179,7 +190,7 @@ public function on_init(): void {
179190
*
180191
* @return bool True if enabled, false if not enabled or init hasn't run yet.
181192
*/
182-
public function feature_is_enabled(): bool {
193+
public static function feature_is_enabled(): bool {
183194
// Fail-open: don't block if init hasn't run yet to avoid FeaturesController translation notices.
184195
if ( ! did_action( 'init' ) ) {
185196
return false;

0 commit comments

Comments
 (0)