Floors the values were worked out with. */ private $floors = array(); /** @var array Ceilings the values were worked out with. */ private $ceilings = array(); /** * Which of the four filters had callbacks when the values were worked out. * An add-on registers its keys and its stored values after the plugin's own * services may already have read a setting, so the values are worked out * again once it has. * * @var string|null */ private $hooked = null; /** * @param array|null $values Overrides layered over the defaults. Supplied, * the stored option is not read at all, so a caller * can work against an explicit configuration. */ public function __construct( ?array $values = null ) { if ( null !== $values ) { $this->base = $values; $this->overrides = true; } } /** * Default of every key, the free part's own and those an add-on adds through * `careback_settings_defaults`. * * @return array */ public static function defaults() { $defaults = self::own_defaults(); /** * Filters the default of every settings key. An add-on adds the keys it * owns; a key the free part declares keeps the free default. See * api/data.md. * * @param array $defaults Key => default. */ $added = apply_filters( self::DEFAULTS_FILTER, $defaults ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_defaults, the final prefix. return is_array( $added ) ? array_merge( $defaults, array_diff_key( $added, $defaults ) ) : $defaults; } /** * Default of every key the free part owns: the keys its option holds. * * @return array */ public static function own_defaults() { return array( 'portal_enabled' => 0, // Tracking module. Off by default: an install that never enables it // behaves exactly as it did before the module existed. 'track_enabled' => 0, // Product names on an unauthenticated endpoint are a purchase profile, // so even the count is opt-in and the names are never shown at all. 'track_show_items' => 0, 'track_order_stage_map' => array(), // Empty on purpose: a default would name one carrier inside a plugin // sold to shops using any of them. // One row per delivery service the shop uses: name, tracking address, // and whether it is in use. Empty, because naming a service here would // put one market inside a plugin sold to every other. 'track_carriers' => array(), 'track_max_attempts' => 8, 'track_window_minutes' => 15, // Floor every answer to the same time. Below 150 ms the refusals can be // told apart, so the code clamps it however low this is set. 'track_floor_ms' => 400, 'return_window_days' => 30, 'statutory_window_days' => 14, 'lookup_max_attempts' => 5, 'lookup_window_minutes' => 15, 'ticket_ttl_minutes' => 30, // Security hardening (all off/neutral by default). 'enable_time_token' => 1, 'min_submit_seconds' => 2, // A whole day, not an hour. The ceiling exists to make a replayed // token useless, and an hour did that at the price of refusing a // customer who left the form open over lunch — or a whole site // behind a page cache. Zero switches the ceiling off. 'max_submit_seconds' => 86400, 'ban_threshold' => 10, 'ban_minutes' => 60, 'retention_extra_years' => 3, 'retention_events_days' => 90, 'admin_recipients' => '', 'fallback_contact' => '', // The two legal texts in the shop's own words (Texts::LEGAL): the link // to the form and the button that sends a withdrawal. Empty: our // translated wording. 'legal_link_text' => '', 'legal_confirm_text' => '', // The shop's returns policy page, linked in the form (N-13); 0: none. 'returns_policy_page' => 0, // Ask for the consent with that page on the voluntary routes (N-12). 'policy_consent' => 0, // Who pays for sending the goods back, read by every text on that cost // (N-116): 'customer' or 'shop'. 'return_cost_payer' => 'customer', 'reasons' => '', // Whether the reason is asked for on a route, and whether it is // required. The statutory route is not listed: it is fixed at optional, // because a withdrawal is made without giving reasons. 'reason_mode_return' => 'optional', 'field_reason' => 1, 'field_comment' => 1, 'field_quantity' => 1, // Item variation details shown in the return form and emails, each on a // line of its own (N-16). Off here, so an update keeps what a shop had; // a new install switches it on (Activator::seed_new_site()). 'variation_display' => 0, // The shop's own fields of the order items, shown the same way // (X-04): a list of [ 'key' => field, 'label' => label ] // (Item_Fields). The custom mode of earlier releases became it. 'item_fields' => array(), 'email_ack_subject' => '', 'email_ack_intro' => '', 'email_ack_closing' => '', 'email_defect_intro' => '', 'email_admin_subject' => '', 'email_admin_body' => '', // Per-status customer emails. Each: enabled (0/1), subject, body (HTML). // All disabled by default — no status email is sent unless turned on. 'status_emails' => array(), // Statutory withdrawal (N-80): on for every install; a shop switches it // off on Settings, only with the confirmation, and the moment and the // user are recorded (a timestamp and a user id; 0 while it is on). 'withdrawal_enabled' => 1, 'withdrawal_off_at' => 0, 'withdrawal_off_by' => 0, // When the periods start (N-76): the order status that means // delivered, and the days added to the moment it was reached. 'delivered_status' => 'completed', 'delivery_buffer_days' => 0, // Delete every Careback table, the key and the data on orders when the // plugin is deleted (N-104). Off: only settings and schedules go. 'delete_data_on_uninstall' => 0, // When the shop told Setup it added a link to the form somewhere the // plugin cannot see (N-02), and its choice to keep WooCommerce's own // withdrawal form beside Careback's (N-81). 0: not told / not chosen. 'link_added_at' => 0, 'woo_form_kept' => 0, ); } /** * The stored values over the defaults. The option is read once; the values * are worked out again when an add-on registers or removes its keys or its * stored values. * * @return array */ public function all() { if ( ! $this->overrides && null === $this->own ) { $stored = get_option( CAREBACK_OPTION, array() ); $this->own = is_array( $stored ) ? $stored : array(); } $hooked = self::hooked(); if ( null === $this->values || $hooked !== $this->hooked ) { if ( ! $this->overrides ) { // The custom item details of earlier releases read as the list // that replaced them until an admin load tidies the option (X-04). $this->base = self::stored( Item_Fields::from_retired( $this->own ) ); } $this->values = wp_parse_args( $this->base, self::defaults() ); $this->floors = self::floors(); $this->ceilings = self::ceilings(); $this->hooked = $hooked; // Fourteen days is the floor of a statutory withdrawal that is on. Off, // the figure only dates the withdrawals filed before, and is never // zero (N-80). if ( empty( $this->values['withdrawal_enabled'] ) && isset( $this->floors['statutory_window_days'] ) ) { $this->floors['statutory_window_days'] = 1; } } return $this->values; } /** * The stored values every reader sees: the free option with what an add-on * stores itself laid in (`careback_settings_stored`). * * @param array $own The free option as stored. * @return array */ private static function stored( array $own ) { /** * Filters the stored settings values. An add-on that keeps its keys in * an option of its own lays them in. See api/data.md. * * @param array $stored The free part's option as stored. */ $stored = apply_filters( self::STORED_FILTER, $own ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_stored, the final prefix. return is_array( $stored ) ? $stored : $own; } /** * Which of the four filters have callbacks, as a short key. * * @return string */ private static function hooked() { return ( has_filter( self::DEFAULTS_FILTER ) ? '1' : '0' ) . ( has_filter( self::FLOORS_FILTER ) ? '1' : '0' ) . ( has_filter( self::CEILINGS_FILTER ) ? '1' : '0' ) . ( has_filter( self::STORED_FILTER ) ? '1' : '0' ); } /** Settings that cannot sensibly be zero, with the least each may be. */ const FLOORS = array( // Fourteen days is the legal minimum in the EU: a shop may widen the // window, never shorten it. Applied on read as well, so a lower figure // saved by 0.79.0 or earlier reads as fourteen (04 §11.7). 'statutory_window_days' => 14, ); /** * Settings that cannot sensibly exceed a figure, with the most each may be. * The free part caps none of its own; an add-on adds its caps through * `careback_settings_ceilings`. * * The goodwill window a shop offers on top of the statutory one, * `return_window_days`, has no ceiling at all. */ const CEILINGS = array(); /** * The floors: the free part's and those an add-on adds. * * @return array */ public static function floors() { /** * Filters the settings that cannot sensibly be zero. See api/data.md. * * @param array $floors Key => the least it may be. */ $floors = apply_filters( self::FLOORS_FILTER, self::FLOORS ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_floors, the final prefix. return is_array( $floors ) ? array_merge( $floors, self::FLOORS ) : self::FLOORS; } /** * The ceilings: those an add-on adds. * * @return array */ public static function ceilings() { /** * Filters the settings that cannot sensibly exceed a figure. See * api/data.md. * * @param array $ceilings Key => the most it may be. */ $ceilings = apply_filters( self::CEILINGS_FILTER, self::CEILINGS ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_ceilings, the final prefix. return is_array( $ceilings ) ? $ceilings : self::CEILINGS; } public function get( $key, $fallback = null ) { $all = $this->all(); $value = array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; // Applied on read, not only on save: a value already in the database from // an older release, or written by anything but this screen, has to answer // the same way. if ( isset( $this->floors[ $key ] ) ) { $value = max( $this->floors[ $key ], (int) $value ); } // Applied on read as well as on save, for the same reason as the floors: // a figure already in the database from an older release has to answer // the same way. if ( isset( $this->ceilings[ $key ] ) ) { $value = min( $this->ceilings[ $key ], (int) $value ); } return $value; } /** * Stores the values handed over, laid over what is stored: a key nobody * sends keeps its stored value. * * A key an add-on declares (`careback_settings_defaults`) is handed to it * (`careback_settings_update`), and the add-on stores it in its own option; * the free option never takes one. Every other key — the free part's own, * and one no add-on declares now — goes to the free option, which keeps * every key stored in it (a value an add-on has not taken into its own * option yet is never lost) and gets the free defaults for any own key it * lacks. A call carrying only add-on keys leaves the free option untouched. * * An instance built on overrides stores everything it holds, as before. * * @param array $values Key => value. * @return void */ public function update( array $values ) { $own_defaults = self::own_defaults(); $declared = array_diff_key( self::defaults(), $own_defaults ); if ( $this->overrides ) { $values = wp_parse_args( $values, $this->base ); } $theirs = array_intersect_key( $values, $declared ); $mine = array_diff_key( $values, $declared ); if ( $theirs ) { /** * Fires with the keys of an update that an add-on declares. The * add-on stores them. See api/data.md. * * @param array $values Key => value, add-on keys only. * @param Settings $settings The settings being updated. */ do_action( self::UPDATE_ACTION, $theirs, $this ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_update, the final prefix. } if ( $mine ) { // An instance built on overrides never read the option: what is stored is read now. $stored = null === $this->own ? get_option( CAREBACK_OPTION, array() ) : $this->own; $written = self::retire( wp_parse_args( $mine, wp_parse_args( is_array( $stored ) ? $stored : array(), $own_defaults ) ) ); update_option( CAREBACK_OPTION, $written ); if ( ! $this->overrides ) { $this->own = $written; } } if ( $this->overrides ) { $this->base = $values; } $this->values = null; } /** * The retired keys: this plugin's own and those an add-on adds through * `careback_settings_retired`. A key someone declares now is never one. * * @return string[] */ public static function retired() { /** * Filters the keys of earlier releases that no longer mean anything. See * api/data.md. * * @param string[] $keys Retired keys. */ $added = apply_filters( self::RETIRED_FILTER, self::RETIRED ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_retired, the final prefix. $keys = self::RETIRED; foreach ( is_array( $added ) ? $added : array() as $key ) { if ( is_string( $key ) && '' !== $key ) { $keys[] = $key; } } return array_values( array_diff( array_unique( $keys ), array_keys( self::defaults() ) ) ); } /** * Values of the free option without the retired keys. The single body of * the status emails becomes the body of every status email that has none, * the single tracking address a row of the delivery-service list while * the list is empty, and the custom item details their two fields in the * shop's own item fields (Item_Fields::from_retired()), so a shop keeps * what they did. * * @param array $values The option's values. * @return array */ public static function retire( array $values ) { $body = isset( $values['email_status_body'] ) && is_scalar( $values['email_status_body'] ) ? trim( (string) $values['email_status_body'] ) : ''; if ( '' !== $body ) { $emails = isset( $values['status_emails'] ) && is_array( $values['status_emails'] ) ? $values['status_emails'] : array(); foreach ( array_unique( array_merge( array_keys( $emails ), Statuses::keys() ) ) as $status ) { $row = isset( $emails[ $status ] ) && is_array( $emails[ $status ] ) ? $emails[ $status ] : array( 'enabled' => 0, 'subject' => '', ); if ( ! isset( $row['body'] ) || '' === trim( (string) $row['body'] ) ) { $row['body'] = wp_kses_post( $body ); } $emails[ $status ] = $row; } $values['status_emails'] = $emails; } $address = isset( $values['track_carrier_url_template'] ) && is_scalar( $values['track_carrier_url_template'] ) ? trim( (string) $values['track_carrier_url_template'] ) : ''; if ( '' !== $address && empty( $values['track_carriers'] ) ) { $values['track_carriers'] = Track_Carriers::from_address( $address ); } $values = Item_Fields::from_retired( $values ); return array_diff_key( $values, array_flip( self::retired() ) ); } /** * Writes the free option back without its retired keys, once: nothing * when it holds none. Run on an admin, cron or command-line load * (Plugin::tidy_settings()), never for a visitor. * * @return bool Whether the option was written. */ public function tidy() { if ( $this->overrides ) { return false; } $stored = get_option( CAREBACK_OPTION, array() ); if ( ! is_array( $stored ) || ! array_intersect_key( $stored, array_flip( self::retired() ) ) ) { return false; } $written = self::retire( $stored ); update_option( CAREBACK_OPTION, $written ); $this->own = $written; $this->values = null; return true; } }