array( 'enabled' => false, 'show_when_reached' => true, ), 'classic' => array( 'position' => 'before_order_review', ), 'percentage_base' => 'subtotal_ex_tax', 'delete_data_on_uninstall' => false, ); } /** * JSON Schema of one extra. */ public function extra_schema(): array { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'extrova-extra', 'type' => 'object', 'additionalProperties' => true, 'properties' => array( 'id' => array( 'type' => 'string', 'pattern' => '^(ext_[a-f0-9]{8})?$', ), 'status' => array( 'type' => 'string', 'enum' => self::STATUSES, ), 'type' => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 64, ), 'label' => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 200, ), 'description' => array( 'type' => 'string', 'maxLength' => 1000, ), 'required' => array( 'type' => 'boolean' ), 'position' => array( 'type' => 'integer', 'minimum' => 0, ), 'locations' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), 'pricing' => array( 'type' => 'object' ), 'tax' => array( 'type' => 'object', 'properties' => array( 'taxable' => array( 'type' => 'boolean' ), 'tax_class' => array( 'type' => 'string' ), ), ), 'type_config' => array( 'type' => 'object' ), 'conditions' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ), ), 'meta' => array( 'type' => 'object' ), ), 'required' => array( 'type', 'label' ), ); /** * Filters the JSON Schema of an extra (used by REST and the repository). * * @since 1.0.0 * * @param array $schema JSON Schema. */ return (array) apply_filters( 'extrova_extra_schema', $schema ); } /** * JSON Schema of the settings. */ public function settings_schema(): array { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'extrova-settings', 'type' => 'object', 'additionalProperties' => true, 'properties' => array( 'free_shipping_bar' => array( 'type' => 'object', 'properties' => array( 'enabled' => array( 'type' => 'boolean' ), 'show_when_reached' => array( 'type' => 'boolean' ), ), ), 'classic' => array( 'type' => 'object', 'properties' => array( 'position' => array( 'type' => 'string', 'enum' => self::CLASSIC_POSITIONS, ), ), ), 'percentage_base' => array( 'type' => 'string', 'enum' => self::PERCENTAGE_BASES, ), 'delete_data_on_uninstall' => array( 'type' => 'boolean' ), ), ); /** * Filters the JSON Schema of the general settings. * * @since 1.0.0 * * @param array $schema JSON Schema. */ return (array) apply_filters( 'extrova_settings_schema', $schema ); } /** * Normalises a raw extra (from REST) into its stored array. * * @param array $raw Raw input. * @param Extra|null $existing Existing extra when updating. */ public function normalize_extra( array $raw, ?Extra $existing = null ): array { $type_slug = sanitize_key( (string) ( $raw['type'] ?? ( $existing ? $existing->type : 'checkbox' ) ) ); $type = $this->types->get( $type_slug ); $type_config = is_array( $raw['type_config'] ?? null ) ? $raw['type_config'] : array(); $type_config = $type ? $type->sanitize_config( $type_config ) : self::deep_sanitize( $type_config ); // Per-option pricing blocks (choice and any type using `options`). if ( isset( $type_config['options'] ) && is_array( $type_config['options'] ) ) { foreach ( $type_config['options'] as $index => $option ) { if ( is_array( $option ) && isset( $option['pricing'] ) && is_array( $option['pricing'] ) && array() !== $option['pricing'] ) { $type_config['options'][ $index ]['pricing'] = $this->normalize_pricing( $option['pricing'] ); } } } $locations = array(); foreach ( (array) ( $raw['locations'] ?? ( $existing ? $existing->locations : array( 'checkout' ) ) ) as $location ) { $location = sanitize_key( (string) $location ); if ( '' !== $location && ! in_array( $location, $locations, true ) ) { $locations[] = $location; } } if ( array() === $locations ) { $locations = array( 'checkout' ); } $conditions = array(); foreach ( (array) ( $raw['conditions'] ?? array() ) as $condition ) { if ( is_array( $condition ) ) { $conditions[] = $this->normalize_condition( $condition ); } } $status = (string) ( $raw['status'] ?? 'active' ); if ( array_key_exists( 'meta', $raw ) && is_array( $raw['meta'] ) ) { $meta = self::deep_sanitize( $raw['meta'] ); } else { $meta = $existing ? $existing->meta : array(); } return array( 'id' => $existing ? $existing->id : (string) ( $raw['id'] ?? '' ), 'status' => in_array( $status, self::STATUSES, true ) ? $status : 'active', 'type' => $type_slug, 'label' => sanitize_text_field( (string) ( $raw['label'] ?? '' ) ), 'description' => sanitize_textarea_field( (string) ( $raw['description'] ?? '' ) ), 'required' => rest_sanitize_boolean( $raw['required'] ?? false ), 'position' => absint( $raw['position'] ?? ( $existing ? $existing->position : 0 ) ), 'locations' => $locations, 'pricing' => $this->normalize_pricing( is_array( $raw['pricing'] ?? null ) ? $raw['pricing'] : array() ), 'tax' => $this->normalize_tax( is_array( $raw['tax'] ?? null ) ? $raw['tax'] : array(), $type_slug ), 'type_config' => $type_config, 'conditions' => $conditions, 'meta' => $meta, ); } /** * Normalises a pricing block. * * @param array $pricing Raw pricing. */ public function normalize_pricing( array $pricing ): array { if ( array() === $pricing ) { return array( 'method' => 'fixed', 'amount' => '0', ); } $method_slug = sanitize_key( (string) ( $pricing['method'] ?? 'fixed' ) ); $method = $this->pricing->get( $method_slug ); if ( $method instanceof AbstractPricingMethod ) { return $method->sanitize( $pricing ); } $clean = self::deep_sanitize( $pricing ); $clean['method'] = $method_slug; return $clean; } /** * Normalises one condition. * * @param array $condition Raw condition. */ public function normalize_condition( array $condition ): array { $slug = sanitize_key( (string) ( $condition['type'] ?? '' ) ); $object = $this->conditions->get( $slug ); if ( $object instanceof AbstractCondition ) { return $object->sanitize( $condition ); } $clean = self::deep_sanitize( $condition ); $clean['type'] = $slug; return $clean; } /** * Normalises the tax block. * * @param array $tax Raw tax block. * @param string $type_slug Type slug (tips are not taxable by default). */ public function normalize_tax( array $tax, string $type_slug ): array { $taxable = array_key_exists( 'taxable', $tax ) ? rest_sanitize_boolean( $tax['taxable'] ) : 'tip' !== $type_slug; $class = sanitize_title( (string) ( $tax['tax_class'] ?? '' ) ); if ( '' !== $class && class_exists( '\WC_Tax' ) && ! in_array( $class, \WC_Tax::get_tax_class_slugs(), true ) ) { $class = ''; } return array( 'taxable' => $taxable, 'tax_class' => $class, ); } /** * Normalises settings coming from REST, preserving unknown keys (Pro). * * @param array $raw Raw settings. * @param array $current Current settings. */ public function normalize_settings( array $raw, array $current ): array { $merged = SettingsRepository::merge( $current, array() ); $bar = is_array( $raw['free_shipping_bar'] ?? null ) ? $raw['free_shipping_bar'] : array(); if ( array_key_exists( 'enabled', $bar ) ) { $merged['free_shipping_bar']['enabled'] = rest_sanitize_boolean( $bar['enabled'] ); } if ( array_key_exists( 'show_when_reached', $bar ) ) { $merged['free_shipping_bar']['show_when_reached'] = rest_sanitize_boolean( $bar['show_when_reached'] ); } $position = (string) ( $raw['classic']['position'] ?? $merged['classic']['position'] ); if ( in_array( $position, self::CLASSIC_POSITIONS, true ) ) { $merged['classic']['position'] = $position; } $base = (string) ( $raw['percentage_base'] ?? $merged['percentage_base'] ); if ( in_array( $base, self::PERCENTAGE_BASES, true ) ) { $merged['percentage_base'] = $base; } if ( array_key_exists( 'delete_data_on_uninstall', $raw ) ) { $merged['delete_data_on_uninstall'] = rest_sanitize_boolean( $raw['delete_data_on_uninstall'] ); } $known = array_keys( self::default_settings() ); foreach ( $raw as $key => $value ) { if ( is_string( $key ) && ! in_array( $key, $known, true ) ) { $merged[ sanitize_key( $key ) ] = is_array( $value ) ? self::deep_sanitize( $value ) : self::sanitize_scalar( $value ); } } return $merged; } /** * Recursively sanitises an unknown structure (keys and scalar values). * * @param array $data Data. */ public static function deep_sanitize( array $data ): array { $clean = array(); foreach ( $data as $key => $value ) { $key = is_int( $key ) ? $key : sanitize_text_field( (string) $key ); if ( is_array( $value ) ) { $clean[ $key ] = self::deep_sanitize( $value ); } else { $clean[ $key ] = self::sanitize_scalar( $value ); } } return $clean; } /** * Sanitises a scalar. * * @param mixed $value Value. * @return mixed */ private static function sanitize_scalar( mixed $value ): mixed { if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { return $value; } if ( is_string( $value ) ) { return sanitize_text_field( $value ); } return null; } }