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