Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.18% covered (success)
97.18%
207 / 213
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Partner_Onboarding
98.10% covered (success)
98.10%
207 / 211
84.62% covered (warning)
84.62%
11 / 13
65
0.00% covered (danger)
0.00%
0 / 1
 get_merchant_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_merchant_email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_referral_tracking_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_partner_client_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_platform_managed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 generate_signup_link
98.13% covered (success)
98.13%
105 / 107
0.00% covered (danger)
0.00%
0 / 1
29
 complete_onboarding
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
13
 abandon_onboarding
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 check_merchant_status
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
6
 get_missing_scopes_message
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_required_scopes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 cache_merchant_email
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cleanup
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * PayPal Partner Referrals onboarding handler.
4 *
5 * Implements the "Connect with PayPal" flow using PayPal's Partner Referrals
6 * API (v2) as a THIRD_PARTY integration: the seller grants Automattic's
7 * platform permission to act for them, and no credentials ever reach the site.
8 *
9 * @package automattic/jetpack-paypal-payments
10 * @since 0.9.0
11 * @see https://developer.paypal.com/docs/multiparty/seller-onboarding/build-onboarding/
12 */
13
14namespace Automattic\Jetpack\PaypalPayments;
15
16use Automattic\Jetpack\Connection\Client;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit;
20}
21
22/**
23 * Class PayPal_Partner_Onboarding
24 *
25 * Handles the Partner Referrals onboarding flow for one-click
26 * "Connect with PayPal" merchant setup.
27 */
28class PayPal_Partner_Onboarding {
29
30    /**
31     * WordPress.com proxy route that creates the Partner Referral.
32     *
33     * Automattic's PayPal platform credentials live on WordPress.com, so the
34     * referral is created there rather than from the site.
35     *
36     * @var string
37     */
38    const WPCOM_SIGNUP_LINK_ROUTE = '/paypal/platform/signup-link';
39
40    /**
41     * Transient holding the tracking ID of the referral in progress.
42     *
43     * PayPal's THIRD_PARTY flow hands the site nothing to identify the seller by
44     * once they finish, so the seller is found through the tracking ID the
45     * referral was created with. 30-minute TTL so abandoned flows auto-expire.
46     *
47     * @var string
48     */
49    const TRACKING_ID_TRANSIENT_KEY = 'jetpack_paypal_payment_buttons_tracking_id';
50
51    /**
52     * Option key for storing the onboarded merchant's PayPal merchant ID.
53     *
54     * @var string
55     */
56    const MERCHANT_ID_OPTION_KEY = 'jetpack_paypal_payment_buttons_merchant_id';
57
58    /**
59     * Option key for storing the onboarded merchant's PayPal email.
60     *
61     * @var string
62     */
63    const MERCHANT_EMAIL_OPTION_KEY = 'jetpack_paypal_payment_buttons_merchant_email';
64
65    /**
66     * Option key for storing the onboarding method used.
67     *
68     * @var string
69     */
70    const ONBOARDING_METHOD_OPTION_KEY = 'jetpack_paypal_payment_buttons_onboarding_method';
71
72    /**
73     * Option key for the platform's public client ID, which the JS SDK URL needs
74     * alongside the seller's merchant ID.
75     *
76     * @var string
77     */
78    const PARTNER_CLIENT_ID_OPTION_KEY = 'jetpack_paypal_payment_buttons_partner_client_id';
79
80    /**
81     * Option: the tracking ID this site onboarded the seller with.
82     *
83     * Sent with every proxied call as proof the site referred the seller. PayPal keeps
84     * it after the seller connects another site, which only moves the record's latest ID.
85     *
86     * @var string
87     */
88    const REFERRAL_TRACKING_ID_OPTION_KEY = 'jetpack_paypal_payment_buttons_referral_tracking_id';
89
90    /**
91     * The onboarding method recorded for a referred seller.
92     *
93     * @var string
94     */
95    const ONBOARDING_METHOD = 'partner_referrals';
96
97    /**
98     * Known PayPal scopes for each feature the WordPress.com referral requests.
99     *
100     * The keys must match WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding::ONBOARDING_FEATURES.
101     * PayPal publishes no feature-to-scope map, so these come from a seller who
102     * approved every permission.
103     *
104     * @var array<string, string[]>
105     */
106    private const FEATURE_SCOPES = array(
107        'PAYMENT'                     => array(
108            'https://uri.paypal.com/services/payments/realtimepayment',
109            'https://uri.paypal.com/services/payments/partnerfee',
110            'https://uri.paypal.com/services/payments/payment/authcapture',
111        ),
112        'REFUND'                      => array( 'https://uri.paypal.com/services/payments/refund' ),
113        'ACCESS_MERCHANT_INFORMATION' => array( 'https://uri.paypal.com/services/customer/merchant-integrations/read' ),
114        'PAYMENT_LINKS_AND_BUTTONS'   => array( 'https://uri.paypal.com/services/checkout/payment-resources/readwrite' ),
115    );
116
117    /**
118     * PayPal's website for each environment, linked from the account status notices.
119     *
120     * @var array<string, string>
121     */
122    private const PAYPAL_URLS = array(
123        'sandbox'    => 'https://www.sandbox.paypal.com',
124        'production' => 'https://www.paypal.com',
125    );
126
127    /**
128     * Get the onboarded merchant's PayPal merchant ID.
129     *
130     * @return string The merchant ID, or empty string if not onboarded.
131     */
132    public static function get_merchant_id() {
133        return get_option( self::MERCHANT_ID_OPTION_KEY, '' );
134    }
135
136    /**
137     * Get the onboarded merchant's PayPal email. Only Partner Referrals merchants have one.
138     *
139     * @return string The email, or empty string if not onboarded through Partner Referrals.
140     */
141    public static function get_merchant_email() {
142        return get_option( self::MERCHANT_EMAIL_OPTION_KEY, '' );
143    }
144
145    /**
146     * Get the tracking ID this site onboarded the seller with.
147     *
148     * @return string The tracking ID, or empty string for a site onboarded before it was kept.
149     */
150    public static function get_referral_tracking_id() {
151        return (string) get_option( self::REFERRAL_TRACKING_ID_OPTION_KEY, '' );
152    }
153
154    /**
155     * Get the platform's public client ID, as WordPress.com reported it.
156     *
157     * @return string The client ID, or empty string before the first referral.
158     */
159    public static function get_partner_client_id() {
160        return get_option( self::PARTNER_CLIENT_ID_OPTION_KEY, '' );
161    }
162
163    /**
164     * Whether PayPal calls for this site are made by WordPress.com on a referred seller's behalf.
165     *
166     * Credentials pasted by the merchant take precedence: a site holding its own
167     * credentials calls PayPal directly whatever else is stored.
168     *
169     * @return bool
170     */
171    public static function is_platform_managed() {
172        return '' !== self::get_merchant_id()
173            && self::ONBOARDING_METHOD === get_option( self::ONBOARDING_METHOD_OPTION_KEY, '' )
174            && ! PayPal_OAuth::has_credentials();
175    }
176
177    /**
178     * Generate a Partner Referrals signup link for the merchant.
179     *
180     * The referral itself is built and created by WordPress.com, which holds
181     * Automattic's PayPal platform credentials; this method asks for it through
182     * wpcom/v2/paypal/platform/signup-link using the site's blog token, and
183     * returns the action_url for the PayPal mini-browser lightbox.
184     *
185     * Prerequisite: the site must be connected to WordPress.com.
186     *
187     * @param string $return_url  The URL PayPal redirects to after onboarding.
188     * @param string $environment 'sandbox' or 'production'.
189     * @return array|\WP_Error Array with 'action_url', 'referral_id' and 'tracking_id', or WP_Error.
190     */
191    public static function generate_signup_link( $return_url, $environment = 'production' ) {
192        // Enforce HTTPS on the return URL to protect the onboarding result in transit.
193        if ( 'production' === $environment && 0 !== strpos( $return_url, 'https://' ) ) {
194            return new \WP_Error(
195                'paypal_onboarding_insecure_url',
196                __( 'The return URL must use HTTPS for production onboarding.', 'jetpack-paypal-payments' ),
197                array( 'status' => 400 )
198            );
199        }
200
201        // Every later call names the environment the referral was created for, so
202        // it has to be the stored one.
203        PayPal_OAuth::set_environment( $environment );
204
205        // Automattic's PayPal platform credentials live on WordPress.com, so the
206        // referral is created there and only the resulting URL comes back here.
207        $response = Client::wpcom_json_api_request_as_blog(
208            self::WPCOM_SIGNUP_LINK_ROUTE,
209            '2',
210            array(
211                'method'  => 'POST',
212                'timeout' => 30,
213                'headers' => array(
214                    'Content-Type' => 'application/json',
215                    'Accept'       => 'application/json',
216                ),
217            ),
218            wp_json_encode(
219                array(
220                    'environment'            => $environment,
221                    'return_url'             => $return_url,
222                    'partner_attribution_id' => PayPal_Payment_Buttons::get_partner_attribution_id(),
223                ),
224                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
225            ),
226            'wpcom'
227        );
228
229        if ( is_wp_error( $response ) ) {
230            return new \WP_Error(
231                'paypal_referral_request_failed',
232                sprintf(
233                    /* translators: %s: error message */
234                    __( 'Failed to create PayPal onboarding link: %s', 'jetpack-paypal-payments' ),
235                    $response->get_error_message()
236                )
237            );
238        }
239
240        $status_code = wp_remote_retrieve_response_code( $response );
241        $body        = json_decode( wp_remote_retrieve_body( $response ), true );
242        if ( ! is_array( $body ) ) {
243            $body = array();
244        }
245
246        if ( 201 !== $status_code && 200 !== $status_code ) {
247            /*
248             * Keep the merchant-facing message actionable, but carry PayPal's own
249             * diagnostics (the offending field, the issue code, and the debug ID
250             * PayPal support traces on) through in the error data. Without them a
251             * rejected referral is just a 400 with a generic sentence.
252             */
253            $error_data = array( 'status' => $status_code );
254
255            foreach ( array( 'paypal_error', 'paypal_details', 'paypal_debug_id' ) as $key ) {
256                if ( isset( $body['data'][ $key ] ) ) {
257                    $error_data[ $key ] = $body['data'][ $key ];
258                }
259            }
260
261            if ( ! empty( $body['code'] ) ) {
262                $error_data['platform_error_code'] = $body['code'];
263            }
264
265            if ( ! empty( $body['message'] ) ) {
266                $error_data['paypal_message'] = $body['message'];
267            }
268
269            /*
270             * A `platform_*` code means WordPress.com is missing or misconfigured
271             * the PayPal platform credentials. Retrying will never clear that, so
272             * telling the merchant to try again sends them in circles and hides
273             * the one message that says what to fix. Pass it through instead.
274             */
275            $is_platform_misconfiguration = ! empty( $body['code'] )
276                && 0 === strpos( (string) $body['code'], 'platform_' );
277
278            if ( $is_platform_misconfiguration && ! empty( $body['message'] ) ) {
279                return new \WP_Error(
280                    'paypal_referral_failed',
281                    sanitize_text_field( $body['message'] ),
282                    $error_data
283                );
284            }
285
286            $message = __( 'Could not create a PayPal onboarding link. Please try again or use the manual credentials option.', 'jetpack-paypal-payments' );
287
288            // PayPal's generic top-level sentence never says what was rejected;
289            // its `details` entries name the field and issue, so show those.
290            $issues = array();
291            if ( ! empty( $error_data['paypal_details'] ) && is_array( $error_data['paypal_details'] ) ) {
292                foreach ( $error_data['paypal_details'] as $detail ) {
293                    if ( ! is_array( $detail ) ) {
294                        continue;
295                    }
296                    $issue = trim(
297                        implode(
298                            ' ',
299                            array_filter(
300                                array(
301                                    isset( $detail['field'] ) ? sanitize_text_field( $detail['field'] ) : '',
302                                    isset( $detail['issue'] ) ? sanitize_text_field( $detail['issue'] ) : '',
303                                    isset( $detail['description'] ) ? sanitize_text_field( $detail['description'] ) : '',
304                                )
305                            )
306                        )
307                    );
308                    if ( '' !== $issue ) {
309                        $issues[] = $issue;
310                    }
311                }
312            }
313
314            if ( $issues || ! empty( $error_data['paypal_debug_id'] ) ) {
315                $message .= ' ' . sprintf(
316                    /* translators: 1: what PayPal rejected, 2: PayPal's debug ID. */
317                    __( 'PayPal reported: %1$s (debug ID %2$s).', 'jetpack-paypal-payments' ),
318                    $issues ? implode( '; ', $issues ) : __( 'unknown error', 'jetpack-paypal-payments' ),
319                    ! empty( $error_data['paypal_debug_id'] ) ? sanitize_text_field( $error_data['paypal_debug_id'] ) : '—'
320                );
321            }
322
323            return new \WP_Error(
324                'paypal_referral_failed',
325                $message,
326                $error_data
327            );
328        }
329
330        if ( empty( $body['action_url'] ) ) {
331            return new \WP_Error(
332                'paypal_referral_no_url',
333                __( 'PayPal returned a successful response but no onboarding URL was included.', 'jetpack-paypal-payments' )
334            );
335        }
336
337        // Without the tracking ID the seller cannot be found once they finish.
338        if ( empty( $body['tracking_id'] ) ) {
339            return new \WP_Error(
340                'paypal_referral_no_tracking_id',
341                __( 'WordPress.com created the PayPal onboarding link without a tracking ID.', 'jetpack-paypal-payments' )
342            );
343        }
344
345        $tracking_id = sanitize_text_field( $body['tracking_id'] );
346        set_transient( self::TRACKING_ID_TRANSIENT_KEY, $tracking_id, 30 * MINUTE_IN_SECONDS );
347
348        if ( ! empty( $body['partner_client_id'] ) ) {
349            update_option( self::PARTNER_CLIENT_ID_OPTION_KEY, sanitize_text_field( $body['partner_client_id'] ), false );
350        }
351
352        return array(
353            'action_url'  => $body['action_url'],
354            'referral_id' => $body['referral_id'] ?? '',
355            'tracking_id' => $tracking_id,
356        );
357    }
358
359    /**
360     * Record the seller who just finished the PayPal onboarding flow.
361     *
362     * The seller is looked up through WordPress.com by the tracking ID the
363     * referral was created with. PayPal reports `merchantIdInPayPal` on the
364     * return URL, which this flow rarely sees; when a caller does have it, it
365     * stands in for an expired tracking ID.
366     *
367     * @param string $merchant_id_in_paypal The merchant's PayPal payer ID, when the caller has it.
368     * @return true|\WP_Error True on success, WP_Error on failure.
369     */
370    public static function complete_onboarding( $merchant_id_in_paypal = '' ) {
371        $tracking_id = (string) get_transient( self::TRACKING_ID_TRANSIENT_KEY );
372        $merchant_id = sanitize_text_field( (string) $merchant_id_in_paypal );
373
374        if ( '' === $tracking_id && '' === $merchant_id ) {
375            return new \WP_Error(
376                'paypal_onboarding_no_session',
377                __( 'Onboarding session expired. Please try connecting again.', 'jetpack-paypal-payments' )
378            );
379        }
380
381        $integration = '' !== $tracking_id
382            ? PayPal_Platform_Client::get_merchant_integration( '', $tracking_id )
383            : PayPal_Platform_Client::get_merchant_integration( $merchant_id );
384
385        // A tracking ID PayPal has not tied to a seller yet is not the end of the
386        // road when the caller can name the seller.
387        if ( is_wp_error( $integration ) && '' !== $tracking_id && '' !== $merchant_id ) {
388            $integration = PayPal_Platform_Client::get_merchant_integration( $merchant_id );
389        }
390
391        if ( is_wp_error( $integration ) ) {
392            return $integration;
393        }
394
395        // Checked before anything is written, so a declined permission leaves the site as it was.
396        if ( ! self::has_required_scopes( $integration ) ) {
397            return new \WP_Error(
398                'paypal_onboarding_missing_scopes',
399                self::get_missing_scopes_message(),
400                array( 'status' => 403 )
401            );
402        }
403
404        $merchant_id = sanitize_text_field( (string) ( $integration['merchant_id'] ?? $merchant_id ) );
405        if ( '' === $merchant_id ) {
406            return new \WP_Error(
407                'paypal_onboarding_no_merchant_id',
408                __( 'PayPal did not return a merchant ID for this account. Please try connecting again.', 'jetpack-paypal-payments' ),
409                array( 'status' => 502 )
410            );
411        }
412
413        // Pasted credentials would take precedence over the referral; this
414        // connection replaces them.
415        PayPal_OAuth::delete_credentials();
416
417        update_option( self::MERCHANT_ID_OPTION_KEY, $merchant_id, false );
418        update_option( self::ONBOARDING_METHOD_OPTION_KEY, self::ONBOARDING_METHOD, false );
419        self::cache_merchant_email( $integration );
420
421        // A caller naming the seller instead of a session falls back to the record's
422        // latest ID, which WordPress.com has just checked is this site's.
423        $referral_tracking_id = '' !== $tracking_id
424            ? $tracking_id
425            : sanitize_text_field( (string) ( $integration['tracking_id'] ?? '' ) );
426        if ( '' !== $referral_tracking_id ) {
427            update_option( self::REFERRAL_TRACKING_ID_OPTION_KEY, $referral_tracking_id, false );
428        } else {
429            delete_option( self::REFERRAL_TRACKING_ID_OPTION_KEY );
430        }
431
432        // The tracking ID is single-use.
433        delete_transient( self::TRACKING_ID_TRANSIENT_KEY );
434
435        // Confirm the grant covers the Payment Links & Buttons API before calling the site connected.
436        $api_access = PayPal_OAuth::validate_api_access();
437        if ( is_wp_error( $api_access ) ) {
438            return self::abandon_onboarding( $api_access );
439        }
440
441        return true;
442    }
443
444    /**
445     * Discard a half-finished connection and hand back the reason it failed.
446     *
447     * The seller is recorded before the grant is validated, so a failure past
448     * that point would otherwise leave the site connected while the editor
449     * reports an error -- and the merchant is told to reconnect an account that
450     * every other screen already treats as connected.
451     *
452     * @param \WP_Error $error Why onboarding was abandoned.
453     * @return \WP_Error The same error, once the partial state is gone.
454     */
455    private static function abandon_onboarding( $error ) {
456        delete_option( self::MERCHANT_ID_OPTION_KEY );
457        delete_option( self::MERCHANT_EMAIL_OPTION_KEY );
458        delete_option( self::ONBOARDING_METHOD_OPTION_KEY );
459        delete_option( self::REFERRAL_TRACKING_ID_OPTION_KEY );
460
461        return $error;
462    }
463
464    /**
465     * Check the merchant's integration status with PayPal.
466     *
467     * Verifies that the merchant can receive payments and has confirmed email,
468     * and lists the notices to show the seller.
469     *
470     * @return array|\WP_Error Integration status array, or WP_Error.
471     */
472    public static function check_merchant_status() {
473        $merchant_id = self::get_merchant_id();
474
475        if ( empty( $merchant_id ) ) {
476            return new \WP_Error(
477                'paypal_no_merchant_info',
478                __( 'Merchant integration info not available. Please reconnect your PayPal account.', 'jetpack-paypal-payments' ),
479                array( 'status' => 400 )
480            );
481        }
482
483        $data = PayPal_Platform_Client::get_merchant_integration( $merchant_id, self::get_referral_tracking_id() );
484        if ( is_wp_error( $data ) ) {
485            return $data;
486        }
487
488        self::cache_merchant_email( $data );
489
490        $status = array(
491            'merchant_id'             => $merchant_id,
492            'payments_receivable'     => ! empty( $data['payments_receivable'] ),
493            'primary_email_confirmed' => ! empty( $data['primary_email_confirmed'] ),
494            'products'                => $data['products'] ?? array(),
495            'notices'                 => array(),
496        );
497
498        // Missing permissions take priority over the account flags, so show only that message.
499        if ( ! self::has_required_scopes( $data ) ) {
500            $status['notices'][] = self::get_missing_scopes_message();
501            return $status;
502        }
503
504        $paypal_url = self::PAYPAL_URLS[ PayPal_OAuth::get_environment() ] ?? self::PAYPAL_URLS['production'];
505
506        // PayPal requires this wording and order.
507        if ( ! $status['primary_email_confirmed'] ) {
508            $status['notices'][] = sprintf(
509                /* translators: %s: URL of the PayPal business profile settings page. */
510                __( 'Attention: Please confirm your email address on %s in order to receive payments! You currently cannot receive payments.', 'jetpack-paypal-payments' ),
511                $paypal_url . '/businessprofile/settings'
512            );
513        }
514
515        if ( ! $status['payments_receivable'] ) {
516            $status['notices'][] = sprintf(
517                /* translators: %s: URL of the PayPal website. */
518                __( 'Attention: You currently cannot receive payments due to restriction on your PayPal account. Please reach out to PayPal Customer Support or connect to %s for more information.', 'jetpack-paypal-payments' ),
519                $paypal_url
520            );
521        }
522
523        return $status;
524    }
525
526    /**
527     * The message asking the seller to connect again and approve all permissions.
528     *
529     * @return string Translated message.
530     */
531    private static function get_missing_scopes_message() {
532        return __( "PayPal didn't grant the permissions this block needs. Connect again and approve all permissions.", 'jetpack-paypal-payments' );
533    }
534
535    /**
536     * Whether the seller granted at least one known scope for every requested feature.
537     *
538     * @param array $integration PayPal's merchant integration record.
539     * @return bool
540     */
541    private static function has_required_scopes( array $integration ) {
542        $scopes = array();
543        foreach ( (array) ( $integration['oauth_integrations'] ?? array() ) as $oauth_integration ) {
544            foreach ( (array) ( $oauth_integration['oauth_third_party'] ?? array() ) as $third_party ) {
545                $scopes = array_merge( $scopes, (array) ( $third_party['scopes'] ?? array() ) );
546            }
547        }
548
549        foreach ( self::FEATURE_SCOPES as $feature_scopes ) {
550            if ( ! array_intersect( $feature_scopes, $scopes ) ) {
551                return false;
552            }
553        }
554
555        return true;
556    }
557
558    /**
559     * Cache the email so get_connection_status() can serve it without another PayPal call.
560     *
561     * @param array $integration PayPal's merchant integration record.
562     */
563    private static function cache_merchant_email( array $integration ) {
564        $primary_email = sanitize_email( $integration['primary_email'] ?? '' );
565        if ( '' !== $primary_email ) {
566            update_option( self::MERCHANT_EMAIL_OPTION_KEY, $primary_email, false );
567        }
568    }
569
570    /**
571     * Clean up all Partner Referrals onboarding data.
572     *
573     * Called during disconnect to remove the merchant and any referral in progress.
574     *
575     * @return void
576     */
577    public static function cleanup() {
578        delete_transient( self::TRACKING_ID_TRANSIENT_KEY );
579        delete_option( self::MERCHANT_ID_OPTION_KEY );
580        delete_option( self::MERCHANT_EMAIL_OPTION_KEY );
581        delete_option( self::ONBOARDING_METHOD_OPTION_KEY );
582        delete_option( self::REFERRAL_TRACKING_ID_OPTION_KEY );
583        // Note: the partner client ID is not deleted — it's a site-level config, not per-merchant.
584    }
585}