Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.44% covered (warning)
85.44%
317 / 371
56.25% covered (warning)
56.25%
9 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Attribute_Mapper
85.91% covered (warning)
85.91%
317 / 369
56.25% covered (warning)
56.25%
9 / 16
243.12
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
 merge_into_resource
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
10.07
 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     * Keys a GET returns that a PUT must not send back.
123     *
124     * `payment_link` is this package's own copy of the HATEOAS link.
125     *
126     * @since 0.13.0
127     *
128     * @var string[]
129     */
130    const READ_ONLY_RESOURCE_KEYS = array(
131        'id',
132        'payment_link',
133        'links',
134        'create_time',
135        'update_time',
136        'status',
137        'code_snippets',
138    );
139
140    /**
141     * Top-level keys the block edits, so a save clears one it leaves out.
142     *
143     * With OWNED_LINE_ITEM_KEYS, these are the keys api_response_to_attributes() reads
144     * into editable attributes; change the three together.
145     *
146     * @since 0.13.0
147     *
148     * @var string[]
149     */
150    const OWNED_RESOURCE_KEYS = array(
151        'integration_mode',
152        'return_url',
153        'line_items',
154    );
155
156    /**
157     * Line item keys the block edits, so a save clears one it leaves out.
158     *
159     * `collect_shipping_address` is left off on purpose: PayPal turns address
160     * collection on for a body without it, so it stays as PayPal had it when not sent.
161     *
162     * @since 0.13.0
163     *
164     * @var string[]
165     */
166    const OWNED_LINE_ITEM_KEYS = array(
167        'name',
168        'unit_amount',
169        'description',
170        'product_id',
171        'variants',
172        'adjustable_quantity',
173        'customer_notes',
174        'taxes',
175        'shipping',
176        'handling',
177        'discounts',
178    );
179
180    /**
181     * Convert block attributes to a PayPal API request body.
182     *
183     * Only the tests call this.
184     *
185     * @param array $attributes Block attributes from the editor.
186     * @return array PayPal API request body.
187     */
188    public static function attributes_to_api_request( array $attributes ) {
189        $line_item = array(
190            'name' => sanitize_text_field( $attributes['productName'] ?? '' ),
191        );
192
193        // Product variants (dimensions with options).
194        if ( ! empty( $attributes['variantsEnabled'] ) && ! empty( $attributes['variants']['dimensions'] ) ) {
195            $line_item['variants'] = self::sanitize_variants( $attributes['variants'] );
196        }
197
198        // PayPal errors with "unit_amount is specified at both product level and
199        // variant level" when both are present, so per-option prices replace the
200        // product-level price rather than sitting alongside it.
201        if ( ! self::variants_have_pricing( $line_item['variants'] ?? null ) ) {
202            $line_item['unit_amount'] = array(
203                'currency_code' => sanitize_text_field( $attributes['currencyCode'] ?? 'USD' ),
204                'value'         => sanitize_text_field( $attributes['price'] ?? '0.00' ),
205            );
206        }
207
208        // Optional line item fields.
209        if ( ! empty( $attributes['productDescription'] ) ) {
210            $line_item['description'] = sanitize_text_field( $attributes['productDescription'] );
211        }
212
213        // Adjustable quantity.
214        if ( ! empty( $attributes['adjustableQuantity'] ) && ! empty( $attributes['maxQuantity'] ) ) {
215            $max = absint( $attributes['maxQuantity'] );
216            if ( $max >= 2 ) {
217                $line_item['adjustable_quantity'] = array( 'maximum' => $max );
218            }
219        }
220
221        // Customer notes / custom fields.
222        if ( ! empty( $attributes['customerNotes'] ) && is_array( $attributes['customerNotes'] ) ) {
223            $notes = array();
224            foreach ( $attributes['customerNotes'] as $note ) {
225                $label = sanitize_text_field( $note['label'] ?? '' );
226                if ( '' !== $label ) {
227                    $notes[] = array(
228                        'label'    => $label,
229                        'required' => ! empty( $note['required'] ),
230                    );
231                }
232            }
233            if ( ! empty( $notes ) ) {
234                $line_item['customer_notes'] = $notes;
235            }
236        }
237
238        // Tax configuration.
239        if ( ! empty( $attributes['taxEnabled'] ) && ! empty( $attributes['taxName'] ) ) {
240            $tax_type = sanitize_text_field( $attributes['taxType'] ?? 'PERCENTAGE' );
241            $tax      = array(
242                'name' => sanitize_text_field( $attributes['taxName'] ),
243                'type' => in_array( $tax_type, array( 'PERCENTAGE', 'PREFERENCE' ), true ) ? $tax_type : 'PERCENTAGE',
244            );
245
246            if ( 'PREFERENCE' === $tax_type ) {
247                $tax['value'] = 'PROFILE';
248            } else {
249                $tax['value'] = sanitize_text_field( $attributes['taxValue'] ?? '0' );
250            }
251
252            $line_item['taxes'] = array( $tax );
253        }
254
255        // Shipping configuration.
256        if ( ! empty( $attributes['shippingEnabled'] ) ) {
257            $shipping_type = sanitize_text_field( $attributes['shippingType'] ?? 'FLAT' );
258
259            $shipping = array(
260                'type' => in_array( $shipping_type, array( 'FLAT', 'PREFERENCE' ), true ) ? $shipping_type : 'FLAT',
261            );
262
263            if ( 'PREFERENCE' === $shipping_type ) {
264                $shipping['value'] = 'PROFILE';
265            } else {
266                $shipping['value'] = sanitize_text_field( $attributes['shippingValue'] ?? '0' );
267            }
268
269            $line_item['shipping'] = array( $shipping );
270        }
271
272        if ( ! empty( $attributes['collectShippingAddress'] ) ) {
273            $line_item['collect_shipping_address'] = true;
274        }
275
276        $request = array(
277            'type'             => 'BUY_NOW',
278            'integration_mode' => 'LINK',
279            'reusable'         => 'MULTIPLE',
280            'line_items'       => array( $line_item ),
281        );
282
283        // Optional top-level fields.
284        if ( ! empty( $attributes['returnUrl'] ) ) {
285            $request['return_url'] = esc_url_raw( $attributes['returnUrl'] );
286        }
287
288        return $request;
289    }
290
291    /**
292     * Convert a PayPal API response to block attributes.
293     *
294     * Extracts the relevant fields from the PayPal API response and maps
295     * them to the flat attribute structure used by the block editor.
296     *
297     * @param array $response PayPal API response body (decoded JSON).
298     * @return array Block attributes to store.
299     */
300    public static function api_response_to_attributes( array $response ) {
301        $attributes = array(
302            'isApiManaged' => true,
303            'resourceId'   => sanitize_text_field( $response['id'] ?? '' ),
304            'paymentLink'  => esc_url_raw( $response['payment_link'] ?? '' ),
305        );
306
307        // Extract first line item details.
308        if ( ! empty( $response['line_items'] ) && is_array( $response['line_items'] ) ) {
309            $line_item = $response['line_items'][0];
310
311            $attributes['productName'] = sanitize_text_field( $line_item['name'] ?? '' );
312
313            if ( isset( $line_item['unit_amount'] ) && is_array( $line_item['unit_amount'] ) ) {
314                $attributes['currencyCode'] = sanitize_text_field( $line_item['unit_amount']['currency_code'] ?? 'USD' );
315                $attributes['price']        = sanitize_text_field( $line_item['unit_amount']['value'] ?? '0.00' );
316            }
317
318            if ( ! empty( $line_item['description'] ) ) {
319                // Keep the line breaks PayPal sent back - sanitize_text_field() flattens them.
320                $attributes['productDescription'] = sanitize_textarea_field( $line_item['description'] );
321            }
322
323            if ( isset( $line_item['product_id'] ) && '' !== $line_item['product_id'] ) {
324                $attributes['productId'] = sanitize_text_field( $line_item['product_id'] );
325            }
326
327            if ( ! empty( $line_item['variants']['dimensions'] ) ) {
328                $attributes['variantsEnabled'] = true;
329                $attributes['variants']        = $line_item['variants'];
330
331                // With per-option pricing the currency lives on the options.
332                if ( ! isset( $attributes['currencyCode'] ) ) {
333                    $variant_currency = self::get_variant_currency( $line_item['variants'] );
334                    if ( null !== $variant_currency ) {
335                        $attributes['currencyCode'] = $variant_currency;
336                    }
337                }
338            }
339
340            // Adjustable quantity.
341            if ( ! empty( $line_item['adjustable_quantity']['maximum'] ) ) {
342                $attributes['adjustableQuantity'] = true;
343                $attributes['maxQuantity']        = absint( $line_item['adjustable_quantity']['maximum'] );
344            }
345
346            // Customer notes.
347            if ( ! empty( $line_item['customer_notes'] ) && is_array( $line_item['customer_notes'] ) ) {
348                $attributes['customerNotes'] = array_map(
349                    function ( $note ) {
350                        return array(
351                            'label'    => sanitize_text_field( $note['label'] ?? '' ),
352                            'required' => ! empty( $note['required'] ),
353                        );
354                    },
355                    $line_item['customer_notes']
356                );
357            }
358
359            // Tax configuration.
360            if ( ! empty( $line_item['taxes'] ) && is_array( $line_item['taxes'] ) ) {
361                $tax                      = $line_item['taxes'][0];
362                $attributes['taxEnabled'] = true;
363                $attributes['taxName']    = sanitize_text_field( $tax['name'] ?? 'Sales Tax' );
364                $attributes['taxType']    = sanitize_text_field( $tax['type'] ?? 'PERCENTAGE' );
365                $attributes['taxValue']   = 'PREFERENCE' === $attributes['taxType'] ? '' : sanitize_text_field( $tax['value'] ?? '' );
366            }
367
368            // Shipping configuration. PayPal stores a type and a value, so the
369            // block's mode comes from both.
370            if ( ! empty( $line_item['shipping'] ) && is_array( $line_item['shipping'] ) ) {
371                $shipping = $line_item['shipping'][0];
372                $type     = sanitize_text_field( $shipping['type'] ?? 'FLAT' );
373                $value    = sanitize_text_field( $shipping['value'] ?? '' );
374                $extra    = sanitize_text_field( $shipping['additional_unit_value'] ?? '' );
375
376                $attributes['shippingEnabled'] = true;
377
378                if ( 'PREFERENCE' === $type ) {
379                    // `value` tells PROFILE from FREE_SHIPPING. Both modes hide the fee
380                    // field, so clear it.
381                    $attributes['shippingMode']  = 'FREE_SHIPPING' === $value ? 'FREE' : 'PROFILE';
382                    $attributes['shippingValue'] = '';
383                } else {
384                    // `additional_unit_value` tells QUANTITY from FLAT, so a quantity fee
385                    // that left it blank reads back as FLAT. Same charge either way.
386                    $attributes['shippingMode']  = '' !== $extra ? 'QUANTITY' : 'FLAT';
387                    $attributes['shippingValue'] = $value;
388                    if ( '' !== $extra ) {
389                        $attributes['shippingAdditionalValue'] = $extra;
390                    }
391                }
392            }
393
394            // Handling fee. The type is always FLAT, so the amount is all that comes back.
395            if ( ! empty( $line_item['handling'] ) && is_array( $line_item['handling'] ) ) {
396                $handling                      = $line_item['handling'][0];
397                $attributes['handlingEnabled'] = true;
398                $attributes['handlingValue']   = sanitize_text_field( $handling['value'] ?? '' );
399            }
400
401            // Discount. Store the type as PayPal sent it so a type set in PayPal's
402            // dashboard survives a save from the block.
403            if ( ! empty( $line_item['discounts'] ) && is_array( $line_item['discounts'] ) ) {
404                $discount = $line_item['discounts'][0] ?? null;
405                if ( is_array( $discount ) ) {
406                    $attributes['discountEnabled'] = true;
407                    $attributes['discountType']    = sanitize_text_field( $discount['type'] ?? 'FLAT' );
408                    $attributes['discountValue']   = sanitize_text_field( $discount['value'] ?? '' );
409                }
410            }
411
412            // The payment is the source of truth, so an absent key reads as off.
413            $attributes['collectShippingAddress'] = ! empty( $line_item['collect_shipping_address'] );
414        }
415
416        // Extract return_url if present.
417        if ( ! empty( $response['return_url'] ) ) {
418            $attributes['returnUrl'] = esc_url_raw( $response['return_url'] );
419        }
420
421        // Extract payment_link from HATEOAS links if not in top-level field.
422        if ( empty( $attributes['paymentLink'] ) && ! empty( $response['links'] ) && is_array( $response['links'] ) ) {
423            foreach ( $response['links'] as $link ) {
424                if ( isset( $link['rel'] ) && 'payment_link' === $link['rel'] && ! empty( $link['href'] ) ) {
425                    $attributes['paymentLink'] = esc_url_raw( $link['href'] );
426                    break;
427                }
428            }
429        }
430
431        // The payment's own mode, so a LINK or QR block re-sends it instead of downgrading
432        // a stacked payment. Anything outside the REST enum becomes '', since storing it
433        // would 400 every later save.
434        $mode                          = sanitize_text_field( $response['integration_mode'] ?? '' );
435        $attributes['integrationMode'] = in_array( $mode, array( 'LINK', 'BUTTON' ), true ) ? $mode : '';
436
437        // The SDK URL the stacked format draws with. Only a BUTTON-mode payment has
438        // code_snippets, so LINK mode gives ''.
439        $attributes['scriptSrc'] = self::extract_script_src( $response );
440
441        return $attributes;
442    }
443
444    /**
445     * Lay a block's request body over the payment as PayPal holds it.
446     *
447     * A PUT replaces the whole payment, so a key the block does not model is lost
448     * unless it is carried over from a GET. Owned keys are last-write-wins: set when
449     * the body has them, dropped when it does not, since leaving one out is how a PUT
450     * clears it. Line items merge per index, so a second item PayPal holds survives.
451     *
452     * @since 0.13.0
453     *
454     * @param array $current The payment as a GET returned it.
455     * @param array $update  The body built from the block.
456     * @return array The body to PUT.
457     */
458    public static function merge_into_resource( array $current, array $update ) {
459        $merged = array_diff_key(
460            $current,
461            array_flip( self::READ_ONLY_RESOURCE_KEYS ),
462            array_flip( self::OWNED_RESOURCE_KEYS )
463        );
464
465        $current_items = isset( $current['line_items'] ) && is_array( $current['line_items'] )
466            ? $current['line_items']
467            : array();
468
469        foreach ( $update as $key => $value ) {
470            if ( 'line_items' === $key ) {
471                continue;
472            }
473            $merged[ $key ] = $value;
474        }
475
476        if ( isset( $update['line_items'] ) && is_array( $update['line_items'] ) ) {
477            $merged['line_items'] = $current_items;
478            foreach ( $update['line_items'] as $index => $item ) {
479                $current_item = isset( $current_items[ $index ] ) && is_array( $current_items[ $index ] )
480                    ? $current_items[ $index ]
481                    : array();
482
483                $merged['line_items'][ $index ] = array_merge(
484                    array_diff_key( $current_item, array_flip( self::OWNED_LINE_ITEM_KEYS ) ),
485                    $item
486                );
487            }
488        }
489
490        return $merged;
491    }
492
493    /**
494     * Extract the PayPal SDK URL from a payment's stacked code snippet.
495     *
496     * PayPal returns a snippet per framework; the HTML one has a plain `<script src="…">`.
497     * The URL includes the merchant's client-id and currency, so it has to be read back.
498     *
499     * @param array $response The payment resource from the API.
500     * @return string The sanitized SDK URL, or '' when there is none.
501     */
502    private static function extract_script_src( array $response ) {
503        $snippets = $response['code_snippets']['stacked'] ?? null;
504        if ( ! is_array( $snippets ) ) {
505            return '';
506        }
507
508        foreach ( $snippets as $snippet ) {
509            if ( ! is_array( $snippet ) || 'HTML' !== ( $snippet['framework'] ?? '' ) ) {
510                continue;
511            }
512
513            // Match on the script tag: the first `src=` in PayPal's snippet may belong
514            // to something else.
515            if ( ! preg_match( '/<script[^>]+src=[\'"]([^\'"]+)[\'"]/i', (string) ( $snippet['head'] ?? '' ), $matches ) ) {
516                continue;
517            }
518
519            $url = PayPal_Payment_Buttons::sanitize_paypal_script_url(
520                html_entity_decode( $matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8' )
521            );
522
523            return false === $url ? '' : $url;
524        }
525
526        return '';
527    }
528
529    /**
530     * Validate block attributes before sending to the PayPal API.
531     *
532     * Checks required fields, format constraints, and business rules.
533     * Returns WP_Error on failure with specific error codes for each validation issue.
534     *
535     * @param array $attributes Block attributes to validate.
536     * @return true|WP_Error True if valid, WP_Error with details on failure.
537     */
538    public static function validate_attributes( array $attributes ) {
539        // trim() throws on an array, so only a string counts as a name.
540        $product_name = is_string( $attributes['productName'] ?? null ) ? $attributes['productName'] : '';
541
542        // Required: product name (reject empty and whitespace-only).
543        if ( empty( $product_name ) || '' === trim( $product_name ) ) {
544            return new WP_Error(
545                'missing_product_name',
546                __( 'Product name is required.', 'jetpack-paypal-payments' ),
547                array( 'status' => 400 )
548            );
549        }
550
551        if ( mb_strlen( sanitize_text_field( $product_name ) ) > self::MAX_NAME_LENGTH ) {
552            return new WP_Error(
553                'product_name_too_long',
554                /* translators: %d: maximum allowed characters */
555                sprintf( __( 'Product name must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_NAME_LENGTH ),
556                array( 'status' => 400 )
557            );
558        }
559
560        // Anything sanitize_variants() would drop must be rejected here instead,
561        // or the pricing checks below run against options PayPal never receives.
562        if ( ! empty( $attributes['variantsEnabled'] ) && ! empty( $attributes['variants']['dimensions'] ) ) {
563            $variant_structure_error = self::validate_variant_structure( $attributes['variants'] );
564            if ( is_wp_error( $variant_structure_error ) ) {
565                return $variant_structure_error;
566            }
567        }
568
569        // Required: price â€” unless the product options carry their own prices,
570        // in which case PayPal takes the amount from the options instead.
571        $uses_variant_pricing = ! empty( $attributes['variantsEnabled'] )
572            && self::variants_have_pricing( $attributes['variants'] ?? null );
573
574        $has_price = isset( $attributes['price'] ) && '' !== $attributes['price'];
575
576        if ( ! $has_price && ! $uses_variant_pricing ) {
577            return new WP_Error(
578                'missing_price',
579                __( 'Price is required.', 'jetpack-paypal-payments' ),
580                array( 'status' => 400 )
581            );
582        }
583
584        // The currency decides how many decimals a price may carry, so the
585        // price checks need it before it is itself validated below.
586        $currency = strtoupper( sanitize_text_field( (string) ( $attributes['currencyCode'] ?? 'USD' ) ) );
587
588        if ( $has_price && ! self::is_valid_price( $attributes['price'], $currency ) ) {
589            return new WP_Error(
590                'invalid_price',
591                self::get_invalid_price_message( $currency ),
592                array( 'status' => 400 )
593            );
594        }
595
596        if ( $uses_variant_pricing ) {
597            $variant_price_error = self::validate_variant_pricing( $attributes['variants'], $currency );
598            if ( is_wp_error( $variant_price_error ) ) {
599                return $variant_price_error;
600            }
601        }
602
603        // Required: currency code.
604        if ( ! in_array( $currency, self::SUPPORTED_CURRENCIES, true ) ) {
605            return new WP_Error(
606                'invalid_currency',
607                __( 'Unsupported currency code.', 'jetpack-paypal-payments' ),
608                array( 'status' => 400 )
609            );
610        }
611
612        // Optional: description length.
613        if ( ! empty( $attributes['productDescription'] ) ) {
614            // Count it as the merchant wrote it - sanitize_text_field() collapses
615            // newlines, so a long description measures short.
616            $description = sanitize_textarea_field( $attributes['productDescription'] );
617            if ( mb_strlen( $description ) > self::MAX_DESCRIPTION_LENGTH ) {
618                return new WP_Error(
619                    'description_too_long',
620                    /* translators: %d: maximum allowed characters */
621                    sprintf( __( 'Description must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_DESCRIPTION_LENGTH ),
622                    array( 'status' => 400 )
623                );
624            }
625        }
626
627        // Optional: product id length.
628        if ( ! empty( $attributes['productId'] ) ) {
629            $product_id = sanitize_text_field( $attributes['productId'] );
630            if ( mb_strlen( $product_id ) > self::MAX_PRODUCT_ID_LENGTH ) {
631                return new WP_Error(
632                    'product_id_too_long',
633                    /* translators: %d: maximum allowed characters */
634                    sprintf( __( 'Product ID must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_PRODUCT_ID_LENGTH ),
635                    array( 'status' => 400 )
636                );
637            }
638        }
639
640        // Optional: button text length.
641        if ( ! empty( $attributes['buttonText'] ) ) {
642            $button_text = sanitize_text_field( $attributes['buttonText'] );
643            if ( mb_strlen( $button_text ) > self::MAX_BUTTON_TEXT_LENGTH ) {
644                return new WP_Error(
645                    'button_text_too_long',
646                    /* translators: %d: maximum allowed characters */
647                    sprintf( __( 'Button text must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_BUTTON_TEXT_LENGTH ),
648                    array( 'status' => 400 )
649                );
650            }
651        }
652
653        // Optional: return URL validation.
654        if ( ! empty( $attributes['returnUrl'] ) ) {
655            $return_url = esc_url_raw( $attributes['returnUrl'] );
656            // wp_http_validate_url() accepts `//example.com`, so check the scheme as well.
657            if ( empty( $return_url ) || ! wp_http_validate_url( $return_url ) || ! preg_match( '#^https?://#', $return_url ) ) {
658                return new WP_Error(
659                    'invalid_return_url',
660                    __( 'Return URL must be a valid URL.', 'jetpack-paypal-payments' ),
661                    array( 'status' => 400 )
662                );
663            }
664            if ( mb_strlen( $return_url ) > self::MAX_RETURN_URL_LENGTH ) {
665                return new WP_Error(
666                    'return_url_too_long',
667                    /* translators: %d: maximum allowed characters */
668                    sprintf( __( 'Return URL must be %d characters or fewer.', 'jetpack-paypal-payments' ), self::MAX_RETURN_URL_LENGTH ),
669                    array( 'status' => 400 )
670                );
671            }
672        }
673
674        return true;
675    }
676
677    /**
678     * Validate a resource ID format (PLB-XXXXXXXXXXXX).
679     *
680     * @param string $resource_id The resource ID to validate.
681     * @return bool True if valid format.
682     */
683    public static function is_valid_resource_id( $resource_id ) {
684        return (bool) preg_match( '/^PLB-[A-Za-z0-9]+$/', $resource_id );
685    }
686
687    /**
688     * Check whether block attributes indicate a V2 API-managed block.
689     *
690     * @param array $attributes Block attributes.
691     * @return bool True if the block is managed via the PayPal API (V2).
692     */
693    public static function is_api_managed( array $attributes ) {
694        return ! empty( $attributes['isApiManaged'] ) && true === $attributes['isApiManaged'];
695    }
696
697    /**
698     * Merge API response attributes into existing block attributes.
699     *
700     * After a create or update API call, merge the response data back
701     * into the block attributes without overwriting frontend-only fields
702     * like buttonText and buttonType.
703     *
704     * @param array $existing_attributes Current block attributes.
705     * @param array $response_attributes Attributes extracted from API response.
706     * @return array Merged attributes.
707     */
708    public static function merge_response_attributes( array $existing_attributes, array $response_attributes ) {
709        // Frontend-only fields that should not be overwritten by API response.
710        $preserve_keys = array( 'buttonText', 'buttonType' );
711
712        $merged = array_merge( $existing_attributes, $response_attributes );
713
714        // Restore preserved frontend-only values.
715        foreach ( $preserve_keys as $key ) {
716            if ( isset( $existing_attributes[ $key ] ) ) {
717                $merged[ $key ] = $existing_attributes[ $key ];
718            }
719        }
720
721        return $merged;
722    }
723
724    /**
725     * Whether PayPal prices a currency without decimals.
726     *
727     * PayPal rejects a decimal amount in these currencies outright rather than
728     * rounding it. The legacy currency table already records which they are.
729     *
730     * @since 0.9.0
731     *
732     * @param string $currency ISO currency code.
733     * @return bool True for JPY, HUF and TWD.
734     */
735    public static function is_zero_decimal_currency( $currency ) {
736        $currency = strtoupper( (string) $currency );
737
738        return in_array( $currency, self::SUPPORTED_CURRENCIES, true )
739            && isset( \PayPal_Payments_Currencies::CURRENCIES[ $currency ]['decimal'] )
740            && 0 === \PayPal_Payments_Currencies::CURRENCIES[ $currency ]['decimal'];
741    }
742
743    /**
744     * The message for a price PayPal would not accept in the given currency.
745     *
746     * @since 0.9.0
747     *
748     * @param string $currency ISO currency code.
749     * @return string Translated message.
750     */
751    private static function get_invalid_price_message( $currency ) {
752        if ( self::is_zero_decimal_currency( $currency ) ) {
753            /* translators: %s: currency code, e.g. JPY */
754            return sprintf( __( 'Prices in %s must be whole numbers (e.g., "1500").', 'jetpack-paypal-payments' ), $currency );
755        }
756
757        return __( 'Price must be a valid positive number (e.g., "29.99").', 'jetpack-paypal-payments' );
758    }
759
760    /**
761     * Validate a price string.
762     *
763     * The price must be a positive number with at most two decimal places, or
764     * a whole number in a currency PayPal prices without decimals. PayPal
765     * requires string format prices like "29.99".
766     *
767     * @param string $price    The price string to validate.
768     * @param string $currency ISO currency code the price is in.
769     * @return bool True if valid.
770     */
771    private static function is_valid_price( $price, $currency = 'USD' ) {
772        // Must be a string representation of a positive decimal number.
773        if ( ! is_string( $price ) && ! is_numeric( $price ) ) {
774            return false;
775        }
776
777        $price = (string) $price;
778
779        $pattern = self::is_zero_decimal_currency( $currency ) ? '/^\d+$/' : '/^\d+(\.\d{1,2})?$/';
780        if ( ! preg_match( $pattern, $price ) ) {
781            return false;
782        }
783
784        // Must be greater than zero.
785        if ( (float) $price <= 0 ) {
786            return false;
787        }
788
789        return true;
790    }
791
792    /**
793     * Find the currency the per-option prices are in.
794     *
795     * @since 0.9.0
796     *
797     * @param array|null $variants Variants structure (block or API shape).
798     * @return string|null The first priced option's currency code, or null when none is priced.
799     */
800    private static function get_variant_currency( $variants ) {
801        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
802            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
803                if ( ! empty( $option['unit_amount']['currency_code'] ) ) {
804                    return sanitize_text_field( $option['unit_amount']['currency_code'] );
805                }
806            }
807        }
808
809        return null;
810    }
811
812    /**
813     * Whether a variants structure carries per-option pricing.
814     *
815     * PayPal rejects a line item that specifies `unit_amount` at both the
816     * product level and the variant level, so the two are mutually exclusive.
817     * Once any option in the primary dimension has its own amount, the
818     * product-level amount must be omitted.
819     *
820     * @since 0.9.0
821     *
822     * @param array|null $variants Variants structure (block or API shape).
823     * @return bool True when at least one option carries its own amount.
824     */
825    public static function variants_have_pricing( $variants ) {
826        if ( empty( $variants['dimensions'] ) || ! is_array( $variants['dimensions'] ) ) {
827            return false;
828        }
829
830        foreach ( $variants['dimensions'] as $dimension ) {
831            if ( empty( $dimension['primary'] ) || empty( $dimension['options'] ) || ! is_array( $dimension['options'] ) ) {
832                continue;
833            }
834
835            foreach ( $dimension['options'] as $option ) {
836                if ( '' !== trim( (string) ( $option['unit_amount']['value'] ?? '' ) ) ) {
837                    return true;
838                }
839            }
840        }
841
842        return false;
843    }
844
845    /**
846     * Validate the shape of a variants structure.
847     *
848     * Checks group and option names and counts. validate_variant_pricing() covers the amounts.
849     *
850     * @since 0.9.0
851     *
852     * @param array $variants Variants structure (block or API shape).
853     * @return true|WP_Error True when valid, WP_Error otherwise.
854     */
855    private static function validate_variant_structure( array $variants ) {
856        $dimensions = $variants['dimensions'] ?? array();
857
858        if ( ! is_array( $dimensions ) || count( $dimensions ) > self::MAX_VARIANT_DIMENSIONS ) {
859            return new WP_Error(
860                'too_many_variant_dimensions',
861                /* translators: %d: maximum number of option groups */
862                sprintf( __( 'A product can have at most %d option groups.', 'jetpack-paypal-payments' ), self::MAX_VARIANT_DIMENSIONS ),
863                array( 'status' => 400 )
864            );
865        }
866
867        foreach ( $dimensions as $dimension ) {
868            if ( ! is_array( $dimension ) || '' === trim( sanitize_text_field( (string) ( $dimension['name'] ?? '' ) ) ) ) {
869                return new WP_Error(
870                    'missing_variant_name',
871                    __( 'Every option group needs a name.', 'jetpack-paypal-payments' ),
872                    array( 'status' => 400 )
873                );
874            }
875
876            $options = $dimension['options'] ?? array();
877            if ( ! is_array( $options ) || count( $options ) > self::MAX_VARIANT_OPTIONS ) {
878                return new WP_Error(
879                    'too_many_variant_options',
880                    /* translators: %d: maximum number of options per group */
881                    sprintf( __( 'An option group can have at most %d options.', 'jetpack-paypal-payments' ), self::MAX_VARIANT_OPTIONS ),
882                    array( 'status' => 400 )
883                );
884            }
885
886            foreach ( $options as $option ) {
887                if ( ! is_array( $option ) || '' === trim( sanitize_text_field( (string) ( $option['label'] ?? '' ) ) ) ) {
888                    return new WP_Error(
889                        'missing_variant_label',
890                        __( 'Every product option needs a label.', 'jetpack-paypal-payments' ),
891                        array( 'status' => 400 )
892                    );
893                }
894            }
895        }
896
897        return true;
898    }
899
900    /**
901     * Validate per-option pricing.
902     *
903     * Per-option prices replace the product-level price, so they are
904     * all-or-nothing: every option in the primary dimension must carry a
905     * valid amount once any of them does.
906     *
907     * @since 0.9.0
908     *
909     * @param array|null $variants Variants structure (block or API shape).
910     * @param string     $currency ISO currency code the option prices are in.
911     * @return true|WP_Error True when valid, WP_Error otherwise.
912     */
913    private static function validate_variant_pricing( $variants, $currency = 'USD' ) {
914        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
915            if ( empty( $dimension['primary'] ) ) {
916                continue;
917            }
918
919            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
920                $value = trim( (string) ( $option['unit_amount']['value'] ?? '' ) );
921
922                if ( '' === $value ) {
923                    return new WP_Error(
924                        'missing_variant_price',
925                        __( 'Every product option must have a price when any option in the group has one.', 'jetpack-paypal-payments' ),
926                        array( 'status' => 400 )
927                    );
928                }
929
930                if ( ! self::is_valid_price( $value, $currency ) ) {
931                    if ( self::is_zero_decimal_currency( $currency ) ) {
932                        /* translators: %s: currency code, e.g. JPY */
933                        $message = sprintf( __( 'Product option prices in %s must be whole numbers (e.g., "1500").', 'jetpack-paypal-payments' ), $currency );
934                    } else {
935                        $message = __( 'Product option prices must be valid positive numbers (e.g., "29.99").', 'jetpack-paypal-payments' );
936                    }
937
938                    return new WP_Error( 'invalid_variant_price', $message, array( 'status' => 400 ) );
939                }
940            }
941        }
942
943        return true;
944    }
945
946    /**
947     * Sanitize and validate a variants structure for the PayPal API.
948     *
949     * Enforces: max 5 dimensions, max 10 options per dimension,
950     * only the primary dimension may have per-option pricing.
951     *
952     * @param array $variants Raw variants from block attributes.
953     * @return array Sanitized variants ready for the API.
954     */
955    private static function sanitize_variants( array $variants ) {
956        if ( empty( $variants['dimensions'] ) || ! is_array( $variants['dimensions'] ) ) {
957            return array( 'dimensions' => array() );
958        }
959
960        $sanitized_dimensions = array();
961        $count                = 0;
962
963        foreach ( $variants['dimensions'] as $dimension ) {
964            if ( ++$count > self::MAX_VARIANT_DIMENSIONS ) {
965                break;
966            }
967
968            $dim = array(
969                'name'    => sanitize_text_field( $dimension['name'] ?? '' ),
970                'primary' => ! empty( $dimension['primary'] ),
971                'options' => array(),
972            );
973
974            if ( empty( $dim['name'] ) ) {
975                continue;
976            }
977
978            $option_count = 0;
979            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
980                if ( ++$option_count > self::MAX_VARIANT_OPTIONS ) {
981                    break;
982                }
983
984                $opt = array(
985                    'label' => sanitize_text_field( $option['label'] ?? '' ),
986                );
987
988                if ( empty( $opt['label'] ) ) {
989                    continue;
990                }
991
992                // Only the primary dimension can have per-option pricing, and an
993                // empty value is "no price" rather than a price of nothing.
994                if ( $dim['primary'] && ! empty( $option['unit_amount'] ) && is_array( $option['unit_amount'] ) ) {
995                    $value = trim( sanitize_text_field( (string) ( $option['unit_amount']['value'] ?? '' ) ) );
996                    if ( '' !== $value ) {
997                        $opt['unit_amount'] = array(
998                            'currency_code' => sanitize_text_field( $option['unit_amount']['currency_code'] ?? 'USD' ),
999                            'value'         => $value,
1000                        );
1001                    }
1002                }
1003
1004                $dim['options'][] = $opt;
1005            }
1006
1007            if ( ! empty( $dim['options'] ) ) {
1008                $sanitized_dimensions[] = $dim;
1009            }
1010        }
1011
1012        return array( 'dimensions' => $sanitized_dimensions );
1013    }
1014}