Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.06% covered (warning)
85.06%
296 / 348
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Attribute_Mapper
85.55% covered (warning)
85.55%
296 / 346
60.00% covered (warning)
60.00%
9 / 15
229.44
0.00% covered (danger)
0.00%
0 / 1
 attributes_to_api_request
45.61% covered (danger)
45.61%
26 / 57
0.00% covered (danger)
0.00%
0 / 1
99.86
 api_response_to_attributes
85.53% covered (warning)
85.53%
65 / 76
0.00% covered (danger)
0.00%
0 / 1
41.15
 extract_script_src
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 validate_attributes
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
1 / 1
28
 is_valid_resource_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_api_managed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 merge_response_attributes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 is_zero_decimal_currency
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_invalid_price_message
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_valid_price
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 get_variant_currency
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 variants_have_pricing
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 validate_variant_structure
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
11
 validate_variant_pricing
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 sanitize_variants
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
14.62
1<?php
2/**
3 * Attribute mapper between WordPress block attributes and PayPal Pay Links & Buttons API.
4 *
5 * Maps a PayPal API response onto block attributes, and validates them before a save.
6 * The block builds its own request body in utils/request-data.js.
7 *
8 * @package automattic/jetpack-paypal-payments
9 * @since 0.8.0
10 */
11
12namespace Automattic\Jetpack\PaypalPayments;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit;
16}
17
18use WP_Error;
19
20/**
21 * Class PayPal_Attribute_Mapper
22 *
23 * Maps block editor attributes to PayPal API request/response formats.
24 * Provides validation, sanitization, and transformation for Phase 1
25 * (BUY_NOW type, LINK integration mode, single line item).
26 */
27class PayPal_Attribute_Mapper {
28
29    /**
30     * Supported currency codes for Phase 1.
31     *
32     * PayPal supports many currencies, but we validate against the most common
33     * to prevent typos. Full list at:
34     * https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies/
35     *
36     * @var array
37     */
38    const SUPPORTED_CURRENCIES = array(
39        'USD',
40        'EUR',
41        'GBP',
42        'CAD',
43        'AUD',
44        'JPY',
45        'CNY',
46        'CHF',
47        'SEK',
48        'NOK',
49        'DKK',
50        'NZD',
51        'SGD',
52        'HKD',
53        'MXN',
54        'BRL',
55        'PLN',
56        'CZK',
57        'HUF',
58        'ILS',
59        'MYR',
60        'PHP',
61        'TWD',
62        'THB',
63    );
64
65    /**
66     * Maximum number of option groups (variant dimensions) per product.
67     *
68     * @since 0.9.0
69     *
70     * @var int
71     */
72    const MAX_VARIANT_DIMENSIONS = 5;
73
74    /**
75     * Maximum number of options per option group.
76     *
77     * @since 0.9.0
78     *
79     * @var int
80     */
81    const MAX_VARIANT_OPTIONS = 10;
82
83    /**
84     * Maximum product name length.
85     *
86     * @var int
87     */
88    const MAX_NAME_LENGTH = 127;
89
90    /**
91     * Maximum description length.
92     *
93     * Anything longer is a 400 `INVALID_STRING_LENGTH` from PayPal. Mirrored in
94     * `utils/validation.js`; change both together.
95     *
96     * @var int
97     */
98    const MAX_DESCRIPTION_LENGTH = 2048;
99
100    /**
101     * Maximum button text length.
102     *
103     * @var int
104     */
105    const MAX_BUTTON_TEXT_LENGTH = 50;
106
107    /**
108     * Maximum return URL length PayPal accepts.
109     *
110     * @var int
111     */
112    const MAX_RETURN_URL_LENGTH = 1024;
113
114    /**
115     * Maximum product id (SKU) length.
116     *
117     * @var int
118     */
119    const MAX_PRODUCT_ID_LENGTH = 50;
120
121    /**
122     * Convert block attributes to a PayPal API request body.
123     *
124     * Only the tests call this.
125     *
126     * @param array $attributes Block attributes from the editor.
127     * @return array PayPal API request body.
128     */
129    public static function attributes_to_api_request( array $attributes ) {
130        $line_item = array(
131            'name' => sanitize_text_field( $attributes['productName'] ?? '' ),
132        );
133
134        // Product variants (dimensions with options).
135        if ( ! empty( $attributes['variantsEnabled'] ) && ! empty( $attributes['variants']['dimensions'] ) ) {
136            $line_item['variants'] = self::sanitize_variants( $attributes['variants'] );
137        }
138
139        // PayPal errors with "unit_amount is specified at both product level and
140        // variant level" when both are present, so per-option prices replace the
141        // product-level price rather than sitting alongside it.
142        if ( ! self::variants_have_pricing( $line_item['variants'] ?? null ) ) {
143            $line_item['unit_amount'] = array(
144                'currency_code' => sanitize_text_field( $attributes['currencyCode'] ?? 'USD' ),
145                'value'         => sanitize_text_field( $attributes['price'] ?? '0.00' ),
146            );
147        }
148
149        // Optional line item fields.
150        if ( ! empty( $attributes['productDescription'] ) ) {
151            $line_item['description'] = sanitize_text_field( $attributes['productDescription'] );
152        }
153
154        // Adjustable quantity.
155        if ( ! empty( $attributes['adjustableQuantity'] ) && ! empty( $attributes['maxQuantity'] ) ) {
156            $max = absint( $attributes['maxQuantity'] );
157            if ( $max >= 2 ) {
158                $line_item['adjustable_quantity'] = array( 'maximum' => $max );
159            }
160        }
161
162        // Customer notes / custom fields.
163        if ( ! empty( $attributes['customerNotes'] ) && is_array( $attributes['customerNotes'] ) ) {
164            $notes = array();
165            foreach ( $attributes['customerNotes'] as $note ) {
166                $label = sanitize_text_field( $note['label'] ?? '' );
167                if ( '' !== $label ) {
168                    $notes[] = array(
169                        'label'    => $label,
170                        'required' => ! empty( $note['required'] ),
171                    );
172                }
173            }
174            if ( ! empty( $notes ) ) {
175                $line_item['customer_notes'] = $notes;
176            }
177        }
178
179        // Tax configuration.
180        if ( ! empty( $attributes['taxEnabled'] ) && ! empty( $attributes['taxName'] ) ) {
181            $tax_type = sanitize_text_field( $attributes['taxType'] ?? 'PERCENTAGE' );
182            $tax      = array(
183                'name' => sanitize_text_field( $attributes['taxName'] ),
184                'type' => in_array( $tax_type, array( 'PERCENTAGE', 'PREFERENCE' ), true ) ? $tax_type : 'PERCENTAGE',
185            );
186
187            if ( 'PREFERENCE' === $tax_type ) {
188                $tax['value'] = 'PROFILE';
189            } else {
190                $tax['value'] = sanitize_text_field( $attributes['taxValue'] ?? '0' );
191            }
192
193            $line_item['taxes'] = array( $tax );
194        }
195
196        // Shipping configuration.
197        if ( ! empty( $attributes['shippingEnabled'] ) ) {
198            $shipping_type = sanitize_text_field( $attributes['shippingType'] ?? 'FLAT' );
199
200            $shipping = array(
201                'type' => in_array( $shipping_type, array( 'FLAT', 'PREFERENCE' ), true ) ? $shipping_type : 'FLAT',
202            );
203
204            if ( 'PREFERENCE' === $shipping_type ) {
205                $shipping['value'] = 'PROFILE';
206            } else {
207                $shipping['value'] = sanitize_text_field( $attributes['shippingValue'] ?? '0' );
208            }
209
210            $line_item['shipping'] = array( $shipping );
211        }
212
213        if ( ! empty( $attributes['collectShippingAddress'] ) ) {
214            $line_item['collect_shipping_address'] = true;
215        }
216
217        $request = array(
218            'type'             => 'BUY_NOW',
219            'integration_mode' => 'LINK',
220            'reusable'         => 'MULTIPLE',
221            'line_items'       => array( $line_item ),
222        );
223
224        // Optional top-level fields.
225        if ( ! empty( $attributes['returnUrl'] ) ) {
226            $request['return_url'] = esc_url_raw( $attributes['returnUrl'] );
227        }
228
229        return $request;
230    }
231
232    /**
233     * Convert a PayPal API response to block attributes.
234     *
235     * Extracts the relevant fields from the PayPal API response and maps
236     * them to the flat attribute structure used by the block editor.
237     *
238     * @param array $response PayPal API response body (decoded JSON).
239     * @return array Block attributes to store.
240     */
241    public static function api_response_to_attributes( array $response ) {
242        $attributes = array(
243            'isApiManaged' => true,
244            'resourceId'   => sanitize_text_field( $response['id'] ?? '' ),
245            'paymentLink'  => esc_url_raw( $response['payment_link'] ?? '' ),
246        );
247
248        // Extract first line item details.
249        if ( ! empty( $response['line_items'] ) && is_array( $response['line_items'] ) ) {
250            $line_item = $response['line_items'][0];
251
252            $attributes['productName'] = sanitize_text_field( $line_item['name'] ?? '' );
253
254            if ( isset( $line_item['unit_amount'] ) && is_array( $line_item['unit_amount'] ) ) {
255                $attributes['currencyCode'] = sanitize_text_field( $line_item['unit_amount']['currency_code'] ?? 'USD' );
256                $attributes['price']        = sanitize_text_field( $line_item['unit_amount']['value'] ?? '0.00' );
257            }
258
259            if ( ! empty( $line_item['description'] ) ) {
260                // Keep the line breaks PayPal sent back - sanitize_text_field() flattens them.
261                $attributes['productDescription'] = sanitize_textarea_field( $line_item['description'] );
262            }
263
264            if ( isset( $line_item['product_id'] ) && '' !== $line_item['product_id'] ) {
265                $attributes['productId'] = sanitize_text_field( $line_item['product_id'] );
266            }
267
268            if ( ! empty( $line_item['variants']['dimensions'] ) ) {
269                $attributes['variantsEnabled'] = true;
270                $attributes['variants']        = $line_item['variants'];
271
272                // With per-option pricing the currency lives on the options.
273                if ( ! isset( $attributes['currencyCode'] ) ) {
274                    $variant_currency = self::get_variant_currency( $line_item['variants'] );
275                    if ( null !== $variant_currency ) {
276                        $attributes['currencyCode'] = $variant_currency;
277                    }
278                }
279            }
280
281            // Adjustable quantity.
282            if ( ! empty( $line_item['adjustable_quantity']['maximum'] ) ) {
283                $attributes['adjustableQuantity'] = true;
284                $attributes['maxQuantity']        = absint( $line_item['adjustable_quantity']['maximum'] );
285            }
286
287            // Customer notes.
288            if ( ! empty( $line_item['customer_notes'] ) && is_array( $line_item['customer_notes'] ) ) {
289                $attributes['customerNotes'] = array_map(
290                    function ( $note ) {
291                        return array(
292                            'label'    => sanitize_text_field( $note['label'] ?? '' ),
293                            'required' => ! empty( $note['required'] ),
294                        );
295                    },
296                    $line_item['customer_notes']
297                );
298            }
299
300            // Tax configuration.
301            if ( ! empty( $line_item['taxes'] ) && is_array( $line_item['taxes'] ) ) {
302                $tax                      = $line_item['taxes'][0];
303                $attributes['taxEnabled'] = true;
304                $attributes['taxName']    = sanitize_text_field( $tax['name'] ?? 'Sales Tax' );
305                $attributes['taxType']    = sanitize_text_field( $tax['type'] ?? 'PERCENTAGE' );
306                $attributes['taxValue']   = 'PREFERENCE' === $attributes['taxType'] ? '' : sanitize_text_field( $tax['value'] ?? '' );
307            }
308
309            // Shipping configuration. PayPal stores a type and a value, so the
310            // block's mode comes from both.
311            if ( ! empty( $line_item['shipping'] ) && is_array( $line_item['shipping'] ) ) {
312                $shipping = $line_item['shipping'][0];
313                $type     = sanitize_text_field( $shipping['type'] ?? 'FLAT' );
314                $value    = sanitize_text_field( $shipping['value'] ?? '' );
315                $extra    = sanitize_text_field( $shipping['additional_unit_value'] ?? '' );
316
317                $attributes['shippingEnabled'] = true;
318
319                if ( 'PREFERENCE' === $type ) {
320                    // `value` tells PROFILE from FREE_SHIPPING. Both modes hide the fee
321                    // field, so clear it.
322                    $attributes['shippingMode']  = 'FREE_SHIPPING' === $value ? 'FREE' : 'PROFILE';
323                    $attributes['shippingValue'] = '';
324                } else {
325                    // `additional_unit_value` tells QUANTITY from FLAT, so a quantity fee
326                    // that left it blank reads back as FLAT. Same charge either way.
327                    $attributes['shippingMode']  = '' !== $extra ? 'QUANTITY' : 'FLAT';
328                    $attributes['shippingValue'] = $value;
329                    if ( '' !== $extra ) {
330                        $attributes['shippingAdditionalValue'] = $extra;
331                    }
332                }
333            }
334
335            // Handling fee. The type is always FLAT, so the amount is all that comes back.
336            if ( ! empty( $line_item['handling'] ) && is_array( $line_item['handling'] ) ) {
337                $handling                      = $line_item['handling'][0];
338                $attributes['handlingEnabled'] = true;
339                $attributes['handlingValue']   = sanitize_text_field( $handling['value'] ?? '' );
340            }
341
342            // Discount. Store the type as PayPal sent it so a type set in PayPal's
343            // dashboard survives a save from the block.
344            if ( ! empty( $line_item['discounts'] ) && is_array( $line_item['discounts'] ) ) {
345                $discount = $line_item['discounts'][0] ?? null;
346                if ( is_array( $discount ) ) {
347                    $attributes['discountEnabled'] = true;
348                    $attributes['discountType']    = sanitize_text_field( $discount['type'] ?? 'FLAT' );
349                    $attributes['discountValue']   = sanitize_text_field( $discount['value'] ?? '' );
350                }
351            }
352
353            // The payment is the source of truth, so an absent key reads as off.
354            $attributes['collectShippingAddress'] = ! empty( $line_item['collect_shipping_address'] );
355        }
356
357        // Extract return_url if present.
358        if ( ! empty( $response['return_url'] ) ) {
359            $attributes['returnUrl'] = esc_url_raw( $response['return_url'] );
360        }
361
362        // Extract payment_link from HATEOAS links if not in top-level field.
363        if ( empty( $attributes['paymentLink'] ) && ! empty( $response['links'] ) && is_array( $response['links'] ) ) {
364            foreach ( $response['links'] as $link ) {
365                if ( isset( $link['rel'] ) && 'payment_link' === $link['rel'] && ! empty( $link['href'] ) ) {
366                    $attributes['paymentLink'] = esc_url_raw( $link['href'] );
367                    break;
368                }
369            }
370        }
371
372        // The payment's own mode, so a LINK or QR block re-sends it instead of downgrading
373        // a stacked payment. Anything outside the REST enum becomes '', since storing it
374        // would 400 every later save.
375        $mode                          = sanitize_text_field( $response['integration_mode'] ?? '' );
376        $attributes['integrationMode'] = in_array( $mode, array( 'LINK', 'BUTTON' ), true ) ? $mode : '';
377
378        // The SDK URL the stacked format draws with. Only a BUTTON-mode payment has
379        // code_snippets, so LINK mode gives ''.
380        $attributes['scriptSrc'] = self::extract_script_src( $response );
381
382        return $attributes;
383    }
384
385    /**
386     * Extract the PayPal SDK URL from a payment's stacked code snippet.
387     *
388     * PayPal returns a snippet per framework; the HTML one has a plain `<script src="…">`.
389     * The URL includes the merchant's client-id and currency, so it has to be read back.
390     *
391     * @param array $response The payment resource from the API.
392     * @return string The sanitized SDK URL, or '' when there is none.
393     */
394    private static function extract_script_src( array $response ) {
395        $snippets = $response['code_snippets']['stacked'] ?? null;
396        if ( ! is_array( $snippets ) ) {
397            return '';
398        }
399
400        foreach ( $snippets as $snippet ) {
401            if ( ! is_array( $snippet ) || 'HTML' !== ( $snippet['framework'] ?? '' ) ) {
402                continue;
403            }
404
405            // Match on the script tag: the first `src=` in PayPal's snippet may belong
406            // to something else.
407            if ( ! preg_match( '/<script[^>]+src=[\'"]([^\'"]+)[\'"]/i', (string) ( $snippet['head'] ?? '' ), $matches ) ) {
408                continue;
409            }
410
411            $url = PayPal_Payment_Buttons::sanitize_paypal_script_url(
412                html_entity_decode( $matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8' )
413            );
414
415            return false === $url ? '' : $url;
416        }
417
418        return '';
419    }
420
421    /**
422     * Validate block attributes before sending to the PayPal API.
423     *
424     * Checks required fields, format constraints, and business rules.
425     * Returns WP_Error on failure with specific error codes for each validation issue.
426     *
427     * @param array $attributes Block attributes to validate.
428     * @return true|WP_Error True if valid, WP_Error with details on failure.
429     */
430    public static function validate_attributes( array $attributes ) {
431        // trim() throws on an array, so only a string counts as a name.
432        $product_name = is_string( $attributes['productName'] ?? null ) ? $attributes['productName'] : '';
433
434        // Required: product name (reject empty and whitespace-only).
435        if ( empty( $product_name ) || '' === trim( $product_name ) ) {
436            return new WP_Error(
437                'missing_product_name',
438                __( 'Product name is required.', 'jetpack-paypal-payments' ),
439                array( 'status' => 400 )
440            );
441        }
442
443        if ( mb_strlen( sanitize_text_field( $product_name ) ) > self::MAX_NAME_LENGTH ) {
444            return new WP_Error(
445                'product_name_too_long',
446                /* translators: %d: maximum allowed characters */
447                sprintf( __( 'Product name must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_NAME_LENGTH ),
448                array( 'status' => 400 )
449            );
450        }
451
452        // Anything sanitize_variants() would drop must be rejected here instead,
453        // or the pricing checks below run against options PayPal never receives.
454        if ( ! empty( $attributes['variantsEnabled'] ) && ! empty( $attributes['variants']['dimensions'] ) ) {
455            $variant_structure_error = self::validate_variant_structure( $attributes['variants'] );
456            if ( is_wp_error( $variant_structure_error ) ) {
457                return $variant_structure_error;
458            }
459        }
460
461        // Required: price â€” unless the product options carry their own prices,
462        // in which case PayPal takes the amount from the options instead.
463        $uses_variant_pricing = ! empty( $attributes['variantsEnabled'] )
464            && self::variants_have_pricing( $attributes['variants'] ?? null );
465
466        $has_price = isset( $attributes['price'] ) && '' !== $attributes['price'];
467
468        if ( ! $has_price && ! $uses_variant_pricing ) {
469            return new WP_Error(
470                'missing_price',
471                __( 'Price is required.', 'jetpack-paypal-payments' ),
472                array( 'status' => 400 )
473            );
474        }
475
476        // The currency decides how many decimals a price may carry, so the
477        // price checks need it before it is itself validated below.
478        $currency = strtoupper( sanitize_text_field( (string) ( $attributes['currencyCode'] ?? 'USD' ) ) );
479
480        if ( $has_price && ! self::is_valid_price( $attributes['price'], $currency ) ) {
481            return new WP_Error(
482                'invalid_price',
483                self::get_invalid_price_message( $currency ),
484                array( 'status' => 400 )
485            );
486        }
487
488        if ( $uses_variant_pricing ) {
489            $variant_price_error = self::validate_variant_pricing( $attributes['variants'], $currency );
490            if ( is_wp_error( $variant_price_error ) ) {
491                return $variant_price_error;
492            }
493        }
494
495        // Required: currency code.
496        if ( ! in_array( $currency, self::SUPPORTED_CURRENCIES, true ) ) {
497            return new WP_Error(
498                'invalid_currency',
499                __( 'Unsupported currency code.', 'jetpack-paypal-payments' ),
500                array( 'status' => 400 )
501            );
502        }
503
504        // Optional: description length.
505        if ( ! empty( $attributes['productDescription'] ) ) {
506            // Count it as the merchant wrote it - sanitize_text_field() collapses
507            // newlines, so a long description measures short.
508            $description = sanitize_textarea_field( $attributes['productDescription'] );
509            if ( mb_strlen( $description ) > self::MAX_DESCRIPTION_LENGTH ) {
510                return new WP_Error(
511                    'description_too_long',
512                    /* translators: %d: maximum allowed characters */
513                    sprintf( __( 'Description must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_DESCRIPTION_LENGTH ),
514                    array( 'status' => 400 )
515                );
516            }
517        }
518
519        // Optional: product id length.
520        if ( ! empty( $attributes['productId'] ) ) {
521            $product_id = sanitize_text_field( $attributes['productId'] );
522            if ( mb_strlen( $product_id ) > self::MAX_PRODUCT_ID_LENGTH ) {
523                return new WP_Error(
524                    'product_id_too_long',
525                    /* translators: %d: maximum allowed characters */
526                    sprintf( __( 'Product ID must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_PRODUCT_ID_LENGTH ),
527                    array( 'status' => 400 )
528                );
529            }
530        }
531
532        // Optional: button text length.
533        if ( ! empty( $attributes['buttonText'] ) ) {
534            $button_text = sanitize_text_field( $attributes['buttonText'] );
535            if ( mb_strlen( $button_text ) > self::MAX_BUTTON_TEXT_LENGTH ) {
536                return new WP_Error(
537                    'button_text_too_long',
538                    /* translators: %d: maximum allowed characters */
539                    sprintf( __( 'Button text must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_BUTTON_TEXT_LENGTH ),
540                    array( 'status' => 400 )
541                );
542            }
543        }
544
545        // Optional: return URL validation.
546        if ( ! empty( $attributes['returnUrl'] ) ) {
547            $return_url = esc_url_raw( $attributes['returnUrl'] );
548            // wp_http_validate_url() accepts `//example.com`, so check the scheme as well.
549            if ( empty( $return_url ) || ! wp_http_validate_url( $return_url ) || ! preg_match( '#^https?://#', $return_url ) ) {
550                return new WP_Error(
551                    'invalid_return_url',
552                    __( 'Return URL must be a valid URL.', 'jetpack-paypal-payments' ),
553                    array( 'status' => 400 )
554                );
555            }
556            if ( mb_strlen( $return_url ) > self::MAX_RETURN_URL_LENGTH ) {
557                return new WP_Error(
558                    'return_url_too_long',
559                    /* translators: %d: maximum allowed characters */
560                    sprintf( __( 'Return URL must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_RETURN_URL_LENGTH ),
561                    array( 'status' => 400 )
562                );
563            }
564        }
565
566        return true;
567    }
568
569    /**
570     * Validate a resource ID format (PLB-XXXXXXXXXXXX).
571     *
572     * @param string $resource_id The resource ID to validate.
573     * @return bool True if valid format.
574     */
575    public static function is_valid_resource_id( $resource_id ) {
576        return (bool) preg_match( '/^PLB-[A-Za-z0-9]+$/', $resource_id );
577    }
578
579    /**
580     * Check whether block attributes indicate a V2 API-managed block.
581     *
582     * @param array $attributes Block attributes.
583     * @return bool True if the block is managed via the PayPal API (V2).
584     */
585    public static function is_api_managed( array $attributes ) {
586        return ! empty( $attributes['isApiManaged'] ) && true === $attributes['isApiManaged'];
587    }
588
589    /**
590     * Merge API response attributes into existing block attributes.
591     *
592     * After a create or update API call, merge the response data back
593     * into the block attributes without overwriting frontend-only fields
594     * like buttonText and buttonType.
595     *
596     * @param array $existing_attributes Current block attributes.
597     * @param array $response_attributes Attributes extracted from API response.
598     * @return array Merged attributes.
599     */
600    public static function merge_response_attributes( array $existing_attributes, array $response_attributes ) {
601        // Frontend-only fields that should not be overwritten by API response.
602        $preserve_keys = array( 'buttonText', 'buttonType' );
603
604        $merged = array_merge( $existing_attributes, $response_attributes );
605
606        // Restore preserved frontend-only values.
607        foreach ( $preserve_keys as $key ) {
608            if ( isset( $existing_attributes[ $key ] ) ) {
609                $merged[ $key ] = $existing_attributes[ $key ];
610            }
611        }
612
613        return $merged;
614    }
615
616    /**
617     * Whether PayPal prices a currency without decimals.
618     *
619     * PayPal rejects a decimal amount in these currencies outright rather than
620     * rounding it. The legacy currency table already records which they are.
621     *
622     * @since 0.9.0
623     *
624     * @param string $currency ISO currency code.
625     * @return bool True for JPY, HUF and TWD.
626     */
627    public static function is_zero_decimal_currency( $currency ) {
628        $currency = strtoupper( (string) $currency );
629
630        return in_array( $currency, self::SUPPORTED_CURRENCIES, true )
631            && isset( \PayPal_Payments_Currencies::CURRENCIES[ $currency ]['decimal'] )
632            && 0 === \PayPal_Payments_Currencies::CURRENCIES[ $currency ]['decimal'];
633    }
634
635    /**
636     * The message for a price PayPal would not accept in the given currency.
637     *
638     * @since 0.9.0
639     *
640     * @param string $currency ISO currency code.
641     * @return string Translated message.
642     */
643    private static function get_invalid_price_message( $currency ) {
644        if ( self::is_zero_decimal_currency( $currency ) ) {
645            /* translators: %s: currency code, e.g. JPY */
646            return sprintf( __( 'Prices in %s must be whole numbers (e.g., "1500").', 'jetpack-paypal-payments' ), $currency );
647        }
648
649        return __( 'Price must be a valid positive number (e.g., "29.99").', 'jetpack-paypal-payments' );
650    }
651
652    /**
653     * Validate a price string.
654     *
655     * The price must be a positive number with at most two decimal places, or
656     * a whole number in a currency PayPal prices without decimals. PayPal
657     * requires string format prices like "29.99".
658     *
659     * @param string $price    The price string to validate.
660     * @param string $currency ISO currency code the price is in.
661     * @return bool True if valid.
662     */
663    private static function is_valid_price( $price, $currency = 'USD' ) {
664        // Must be a string representation of a positive decimal number.
665        if ( ! is_string( $price ) && ! is_numeric( $price ) ) {
666            return false;
667        }
668
669        $price = (string) $price;
670
671        $pattern = self::is_zero_decimal_currency( $currency ) ? '/^\d+$/' : '/^\d+(\.\d{1,2})?$/';
672        if ( ! preg_match( $pattern, $price ) ) {
673            return false;
674        }
675
676        // Must be greater than zero.
677        if ( (float) $price <= 0 ) {
678            return false;
679        }
680
681        return true;
682    }
683
684    /**
685     * Find the currency the per-option prices are in.
686     *
687     * @since 0.9.0
688     *
689     * @param array|null $variants Variants structure (block or API shape).
690     * @return string|null The first priced option's currency code, or null when none is priced.
691     */
692    private static function get_variant_currency( $variants ) {
693        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
694            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
695                if ( ! empty( $option['unit_amount']['currency_code'] ) ) {
696                    return sanitize_text_field( $option['unit_amount']['currency_code'] );
697                }
698            }
699        }
700
701        return null;
702    }
703
704    /**
705     * Whether a variants structure carries per-option pricing.
706     *
707     * PayPal rejects a line item that specifies `unit_amount` at both the
708     * product level and the variant level, so the two are mutually exclusive.
709     * Once any option in the primary dimension has its own amount, the
710     * product-level amount must be omitted.
711     *
712     * @since 0.9.0
713     *
714     * @param array|null $variants Variants structure (block or API shape).
715     * @return bool True when at least one option carries its own amount.
716     */
717    public static function variants_have_pricing( $variants ) {
718        if ( empty( $variants['dimensions'] ) || ! is_array( $variants['dimensions'] ) ) {
719            return false;
720        }
721
722        foreach ( $variants['dimensions'] as $dimension ) {
723            if ( empty( $dimension['primary'] ) || empty( $dimension['options'] ) || ! is_array( $dimension['options'] ) ) {
724                continue;
725            }
726
727            foreach ( $dimension['options'] as $option ) {
728                if ( '' !== trim( (string) ( $option['unit_amount']['value'] ?? '' ) ) ) {
729                    return true;
730                }
731            }
732        }
733
734        return false;
735    }
736
737    /**
738     * Validate the shape of a variants structure.
739     *
740     * Checks group and option names and counts. validate_variant_pricing() covers the amounts.
741     *
742     * @since 0.9.0
743     *
744     * @param array $variants Variants structure (block or API shape).
745     * @return true|WP_Error True when valid, WP_Error otherwise.
746     */
747    private static function validate_variant_structure( array $variants ) {
748        $dimensions = $variants['dimensions'] ?? array();
749
750        if ( ! is_array( $dimensions ) || count( $dimensions ) > self::MAX_VARIANT_DIMENSIONS ) {
751            return new WP_Error(
752                'too_many_variant_dimensions',
753                /* translators: %d: maximum number of option groups */
754                sprintf( __( 'A product can have at most %d option groups.', 'jetpack-paypal-payments' ), self::MAX_VARIANT_DIMENSIONS ),
755                array( 'status' => 400 )
756            );
757        }
758
759        foreach ( $dimensions as $dimension ) {
760            if ( ! is_array( $dimension ) || '' === trim( sanitize_text_field( (string) ( $dimension['name'] ?? '' ) ) ) ) {
761                return new WP_Error(
762                    'missing_variant_name',
763                    __( 'Every option group needs a name.', 'jetpack-paypal-payments' ),
764                    array( 'status' => 400 )
765                );
766            }
767
768            $options = $dimension['options'] ?? array();
769            if ( ! is_array( $options ) || count( $options ) > self::MAX_VARIANT_OPTIONS ) {
770                return new WP_Error(
771                    'too_many_variant_options',
772                    /* translators: %d: maximum number of options per group */
773                    sprintf( __( 'An option group can have at most %d options.', 'jetpack-paypal-payments' ), self::MAX_VARIANT_OPTIONS ),
774                    array( 'status' => 400 )
775                );
776            }
777
778            foreach ( $options as $option ) {
779                if ( ! is_array( $option ) || '' === trim( sanitize_text_field( (string) ( $option['label'] ?? '' ) ) ) ) {
780                    return new WP_Error(
781                        'missing_variant_label',
782                        __( 'Every product option needs a label.', 'jetpack-paypal-payments' ),
783                        array( 'status' => 400 )
784                    );
785                }
786            }
787        }
788
789        return true;
790    }
791
792    /**
793     * Validate per-option pricing.
794     *
795     * Per-option prices replace the product-level price, so they are
796     * all-or-nothing: every option in the primary dimension must carry a
797     * valid amount once any of them does.
798     *
799     * @since 0.9.0
800     *
801     * @param array|null $variants Variants structure (block or API shape).
802     * @param string     $currency ISO currency code the option prices are in.
803     * @return true|WP_Error True when valid, WP_Error otherwise.
804     */
805    private static function validate_variant_pricing( $variants, $currency = 'USD' ) {
806        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
807            if ( empty( $dimension['primary'] ) ) {
808                continue;
809            }
810
811            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
812                $value = trim( (string) ( $option['unit_amount']['value'] ?? '' ) );
813
814                if ( '' === $value ) {
815                    return new WP_Error(
816                        'missing_variant_price',
817                        __( 'Every product option must have a price when any option in the group has one.', 'jetpack-paypal-payments' ),
818                        array( 'status' => 400 )
819                    );
820                }
821
822                if ( ! self::is_valid_price( $value, $currency ) ) {
823                    if ( self::is_zero_decimal_currency( $currency ) ) {
824                        /* translators: %s: currency code, e.g. JPY */
825                        $message = sprintf( __( 'Product option prices in %s must be whole numbers (e.g., "1500").', 'jetpack-paypal-payments' ), $currency );
826                    } else {
827                        $message = __( 'Product option prices must be valid positive numbers (e.g., "29.99").', 'jetpack-paypal-payments' );
828                    }
829
830                    return new WP_Error( 'invalid_variant_price', $message, array( 'status' => 400 ) );
831                }
832            }
833        }
834
835        return true;
836    }
837
838    /**
839     * Sanitize and validate a variants structure for the PayPal API.
840     *
841     * Enforces: max 5 dimensions, max 10 options per dimension,
842     * only the primary dimension may have per-option pricing.
843     *
844     * @param array $variants Raw variants from block attributes.
845     * @return array Sanitized variants ready for the API.
846     */
847    private static function sanitize_variants( array $variants ) {
848        if ( empty( $variants['dimensions'] ) || ! is_array( $variants['dimensions'] ) ) {
849            return array( 'dimensions' => array() );
850        }
851
852        $sanitized_dimensions = array();
853        $count                = 0;
854
855        foreach ( $variants['dimensions'] as $dimension ) {
856            if ( ++$count > self::MAX_VARIANT_DIMENSIONS ) {
857                break;
858            }
859
860            $dim = array(
861                'name'    => sanitize_text_field( $dimension['name'] ?? '' ),
862                'primary' => ! empty( $dimension['primary'] ),
863                'options' => array(),
864            );
865
866            if ( empty( $dim['name'] ) ) {
867                continue;
868            }
869
870            $option_count = 0;
871            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
872                if ( ++$option_count > self::MAX_VARIANT_OPTIONS ) {
873                    break;
874                }
875
876                $opt = array(
877                    'label' => sanitize_text_field( $option['label'] ?? '' ),
878                );
879
880                if ( empty( $opt['label'] ) ) {
881                    continue;
882                }
883
884                // Only the primary dimension can have per-option pricing, and an
885                // empty value is "no price" rather than a price of nothing.
886                if ( $dim['primary'] && ! empty( $option['unit_amount'] ) && is_array( $option['unit_amount'] ) ) {
887                    $value = trim( sanitize_text_field( (string) ( $option['unit_amount']['value'] ?? '' ) ) );
888                    if ( '' !== $value ) {
889                        $opt['unit_amount'] = array(
890                            'currency_code' => sanitize_text_field( $option['unit_amount']['currency_code'] ?? 'USD' ),
891                            'value'         => $value,
892                        );
893                    }
894                }
895
896                $dim['options'][] = $opt;
897            }
898
899            if ( ! empty( $dim['options'] ) ) {
900                $sanitized_dimensions[] = $dim;
901            }
902        }
903
904        return array( 'dimensions' => $sanitized_dimensions );
905    }
906}