true, 'return_days' => 30, 'window_start' => 'completed', 'return_statuses' => 'completed', 'guest_returns' => true, 'reasons' => "Wrong size or fit\nDamaged or defective\nNot as described\nWrong item sent\nChanged my mind\nArrived too late\nOther", 'require_reason' => true, 'exclude_virtual' => true, 'exclude_sale' => false, 'exclude_categories' => '', 'return_instructions' => '', 'return_address' => '', 'restock_default' => true, 'refund_gateway_default' => true, 'account_endpoint' => 'returns', 'portal_page' => 0, // EU withdrawal (Directive (EU) 2023/2673, Art. 11a CRD). 'withdrawal_enabled' => true, 'withdrawal_days' => 14, 'withdrawal_grace_days' => 0, 'withdrawal_start' => 'completed', 'withdrawal_outside' => 'warn', 'withdrawal_statuses' => "processing\non-hold\ncompleted", 'withdrawal_items' => true, 'withdrawal_exclude_cats' => '', 'withdrawal_page' => 0, 'withdrawal_label' => '', 'withdrawal_confirm_label' => '', 'withdrawal_in_account' => true, 'withdrawal_in_emails' => true, 'withdrawal_in_footer' => false, // Spam protection for the public forms. 'honeypot' => true, 'ip_source' => 'remote_addr', 'min_seconds' => 3, 'rate_limit' => 5, 'turnstile_enabled' => false, 'turnstile_site_key' => '', 'turnstile_secret' => '', // Notifications. 'notify_admin' => true, 'admin_recipients' => '', // Privacy. 'anonymize_ip' => true, // Pro: exchanges. 'exchanges_enabled' => false, 'exchange_scope' => 'variation', 'exchange_upcharge' => 'charge', 'exchange_downcharge' => 'credit', // Pro: store credit. 'credit_enabled' => false, 'credit_bonus' => 0, 'credit_auto_apply' => true, // Pro: labels. 'labels_attach' => true, // Pro: frequent-returner flags. 'flags_enabled' => false, 'flag_count' => 3, 'flag_days' => 90, 'flag_rate' => 50, 'flag_hold' => true, 'flag_checkout_shield' => true, // Pro: automatic approval. 'auto_approve' => false, 'auto_approve_max' => 100, 'auto_approve_reasons' => '', ); } /** * Keys holding the merchant's own content: never touched by "Reset to * recommended". * * @return string[] */ public static function personal_keys() { return array( 'reasons', 'exclude_categories', 'return_instructions', 'return_address', 'portal_page', 'withdrawal_page', 'withdrawal_exclude_cats', 'withdrawal_label', 'withdrawal_confirm_label', 'turnstile_site_key', 'turnstile_secret', 'admin_recipients', 'account_endpoint', 'auto_approve_reasons' ); } /** * Allowed values for enumerated settings. * * @return array */ public static function choices() { return array( 'window_start' => array( 'completed', 'created' ), 'withdrawal_start' => array( 'completed', 'created' ), 'withdrawal_outside' => array( 'warn', 'block' ), 'ip_source' => array( 'remote_addr', 'cloudflare', 'forwarded', 'real_ip' ), 'exchange_scope' => array( 'variation', 'any' ), 'exchange_upcharge' => array( 'charge', 'absorb' ), 'exchange_downcharge' => array( 'credit', 'refund', 'keep' ), ); } /** * Integer bounds as [min, max]. * * @return array */ private static function bounds() { return array( 'return_days' => array( 1, 3650 ), // The statutory period is at least 14 days. 'withdrawal_days' => array( 14, 3650 ), 'withdrawal_grace_days' => array( 0, 90 ), 'portal_page' => array( 0, PHP_INT_MAX ), 'withdrawal_page' => array( 0, PHP_INT_MAX ), 'min_seconds' => array( 0, 60 ), 'rate_limit' => array( 1, 1000 ), 'credit_bonus' => array( 0, 100 ), 'flag_count' => array( 1, 1000 ), 'flag_days' => array( 7, 3650 ), 'flag_rate' => array( 1, 100 ), 'auto_approve_max' => array( 0, 1000000 ), ); } /** * Effective settings: stored values over defaults. * * @return array */ public static function all() { if ( null === self::$cache ) { $stored = get_option( self::OPTION, array() ); self::$cache = self::apply_gates( wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() ) ); } return self::$cache; } /** * Single setting. * * @param string $key Setting key. * @return mixed */ public static function get( $key ) { $all = self::all(); return isset( $all[ $key ] ) ? $all[ $key ] : null; } /** * Merge, sanitize and persist a partial settings update. * * @param array $input Raw values. * @return array Effective settings after saving. */ public static function update( array $input ) { $stored = get_option( self::OPTION, array() ); $current = wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() ); $clean = self::sanitize( array_merge( $current, $input ) ); $before = $current['account_endpoint']; update_option( self::OPTION, $clean ); self::$cache = null; if ( $before !== $clean['account_endpoint'] ) { // The My Account endpoint is a rewrite rule. update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' ); } return self::all(); } /** * Forget the per-request cache (after saving, or in tests). * * @return void */ public static function flush() { self::$cache = null; } /** * Type-safe sanitizing of every known key; unknown keys are dropped. * * @param array $input Raw values. * @return array */ public static function sanitize( array $input ) { $defaults = self::defaults(); $choices = self::choices(); $bounds = self::bounds(); $out = array(); foreach ( $defaults as $key => $default ) { $value = array_key_exists( $key, $input ) ? $input[ $key ] : $default; if ( is_bool( $default ) ) { $out[ $key ] = filter_var( $value, FILTER_VALIDATE_BOOLEAN ); } elseif ( is_int( $default ) ) { $value = is_numeric( $value ) ? (int) $value : $default; $out[ $key ] = isset( $bounds[ $key ] ) ? max( $bounds[ $key ][0], min( $bounds[ $key ][1], $value ) ) : $value; } elseif ( isset( $choices[ $key ] ) ) { $value = sanitize_key( (string) $value ); $out[ $key ] = in_array( $value, $choices[ $key ], true ) ? $value : $default; } elseif ( is_array( $value ) ) { // Multi-selects post arrays; they are stored as lines. $out[ $key ] = implode( "\n", array_map( 'strval', array_filter( $value, 'is_scalar' ) ) ); } else { $out[ $key ] = is_scalar( $value ) ? (string) $value : ''; } } $out['reasons'] = implode( "\n", self::clean_lines( $out['reasons'], array( __CLASS__, 'clean_reason' ), "\n" ) ); // The default list saved unchanged in the site language stays "the // defaults" (shown in each visitor's language), not a custom list. if ( implode( "\n", \ReturnKit\Domain\Reasons::default_labels() ) === $out['reasons'] ) { $out['reasons'] = self::defaults()['reasons']; } $out['auto_approve_reasons'] = implode( "\n", self::clean_lines( $out['auto_approve_reasons'], array( __CLASS__, 'clean_reason' ), "\n" ) ); $out['return_statuses'] = implode( "\n", self::clean_lines( $out['return_statuses'], array( __CLASS__, 'clean_status' ) ) ); $out['withdrawal_statuses'] = implode( "\n", self::clean_lines( $out['withdrawal_statuses'], array( __CLASS__, 'clean_status' ) ) ); $out['exclude_categories'] = implode( "\n", self::clean_lines( $out['exclude_categories'], 'absint' ) ); $out['withdrawal_exclude_cats'] = implode( "\n", self::clean_lines( $out['withdrawal_exclude_cats'], 'absint' ) ); $out['admin_recipients'] = implode( "\n", self::clean_lines( $out['admin_recipients'], array( __CLASS__, 'clean_recipient' ) ) ); $out['return_instructions'] = sanitize_textarea_field( $out['return_instructions'] ); $out['return_address'] = sanitize_textarea_field( $out['return_address'] ); $out['withdrawal_label'] = sanitize_text_field( $out['withdrawal_label'] ); $out['withdrawal_confirm_label'] = sanitize_text_field( $out['withdrawal_confirm_label'] ); $out['turnstile_site_key'] = self::clean_key( $out['turnstile_site_key'] ); $out['turnstile_secret'] = self::clean_key( $out['turnstile_secret'] ); $endpoint = sanitize_title( $out['account_endpoint'] ); $out['account_endpoint'] = '' !== $endpoint ? $endpoint : $defaults['account_endpoint']; if ( '' === $out['return_statuses'] ) { $out['return_statuses'] = $defaults['return_statuses']; } if ( '' === $out['withdrawal_statuses'] ) { $out['withdrawal_statuses'] = $defaults['withdrawal_statuses']; } // Gates are applied when settings are read (all()), not here, so Pro // configuration survives a lapsed license. return $out; } /** * Force Pro-only options off on the free plan. Stored values are kept. * * @param array $settings Settings. * @return array */ private static function apply_gates( array $settings ) { $map = array( 'exchanges' => array( 'exchanges_enabled' ), 'credit' => array( 'credit_enabled' ), 'labels' => array( 'labels_attach' ), 'flags' => array( 'flags_enabled' ), 'rules' => array( 'auto_approve' ), ); foreach ( $map as $feature => $keys ) { if ( ! Gate::can( $feature ) ) { foreach ( $keys as $key ) { $settings[ $key ] = false; } } } return $settings; } /** * A setting text, translated while it still has its default (empty) value. * * @param string $key Setting key. * @return string */ public static function text( $key ) { $value = trim( (string) self::get( $key ) ); if ( '' !== $value ) { return $value; } $fallbacks = array( 'withdrawal_label' => __( 'Withdraw from contract here', 'returnkit' ), 'withdrawal_confirm_label' => __( 'Confirm withdrawal', 'returnkit' ), 'return_instructions' => __( 'Pack the items securely and include your return number. We will refund you as soon as the items arrive.', 'returnkit' ), ); return isset( $fallbacks[ $key ] ) ? $fallbacks[ $key ] : ''; } /** * A list setting (one entry per line) as an array. * * @param string $key Setting key. * @return string[] */ public static function lines( $key ) { $value = (string) self::get( $key ); if ( '' === $value ) { return array(); } return array_values( array_filter( array_map( 'trim', explode( "\n", $value ) ), 'strlen' ) ); } /** * A list of IDs setting as integers. * * @param string $key Setting key. * @return int[] */ public static function ids( $key ) { return array_values( array_filter( array_map( 'absint', self::lines( $key ) ) ) ); } /** * A reason label (single line, max 100 chars), or ''. * * @param string $value Raw entry. * @return string */ public static function clean_reason( $value ) { $value = trim( sanitize_text_field( (string) $value ) ); return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, 100 ) : substr( $value, 0, 100 ); } /** * An order status slug without the wc- prefix, or ''. * * @param string $value Raw status. * @return string */ public static function clean_status( $value ) { $value = sanitize_key( preg_replace( '/^wc-/', '', trim( (string) $value ) ) ); if ( '' === $value ) { return ''; } if ( function_exists( 'wc_get_order_statuses' ) ) { return array_key_exists( 'wc-' . $value, wc_get_order_statuses() ) ? $value : ''; } return $value; } /** * A notification recipient email, or '' when invalid. * * @param string $value Raw entry. * @return string */ public static function clean_recipient( $value ) { $value = strtolower( trim( (string) $value ) ); return is_email( $value ) ? $value : ''; } /** * Clean every line of a list through a normalizer; invalid lines are dropped. * * @param string $text Raw text. * @param callable $normalizer Returns the clean entry or ''/0 to drop it. * @param string $separators Characters that separate entries. * @return string[] */ private static function clean_lines( $text, $normalizer, $separators = "\n," ) { $out = array(); foreach ( preg_split( '/[' . preg_quote( $separators, '/' ) . '\r]+/', (string) $text ) as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } $clean = (string) call_user_func( $normalizer, $line ); if ( '' !== $clean && '0' !== $clean ) { $out[ strtolower( $clean ) ] = $clean; } } return array_values( $out ); } /** * API key characters only. * * @param string $value Raw key. * @return string */ private static function clean_key( $value ) { return (string) preg_replace( '/[^0-9A-Za-z_\-.]/', '', trim( (string) $value ) ); } }