properties: * * admin_label Name of the route on the admin screens, registered as an * English literal like the status names, and shown through * display_admin_label() (step 6, translations). * label_key Texts key of the option the customer picks. * hint_key Texts key of the line under that option. * submit_key Texts key of the submit button while it is picked. * reason_key Texts key of the message when a required reason is missing. * comment_key Texts key of the message when a required comment is missing. * can_be_statutory A request filed on it inside the withdrawal window * carries a statutory declaration. * consumes_units A request on it holds the units it names. * reason_mode Whether the reason is asked: 'off', 'optional' or * 'required' — the default while the shop saved none. * reason_mode_fixed The mode above is the only one; the shop cannot set it. * comment_with_reason A required reason requires the comment as well. * window_reason Past the statutory window, reason and comment are required. * offered callable( Settings ): bool — whether the route can be * filed at all; null for always. * state callable( \WC_Order, Settings ): array{available:bool, * reason:string, visible:bool} — the route on this order. * position Order in which the portal lists the routes; lower first. * review_key Texts key of the heading of the check step on it. * review_type_key Texts key of what the check step's request row says; * '' for the route's own label. * policy_consent The customer accepts the shop's returns policy before * sending it, while the shop asks for that (N-12). Never * on the withdrawal: a right is not bound to a shop's terms. * cost_key Texts key of the check step's row on who pays for * sending the goods back (N-116); '' for none, as on a * route whose goods are not sent back at the customer's * cost. * * The free part's own two routes stay registered whatever a callback returns, * with the properties its rules read (CORE_PROPERTIES): the withdrawal always * exists and can carry a statutory declaration. A callback may rename one, * give it other texts or move it. The list is built once and kept until the * filter's callbacks change (Hook_Cache). */ class Routes { /** Filter that receives the registered routes, key => properties. */ const FILTER = 'careback_routes'; /** * Filter: the name an operator reads for a route ( string $name, string * $route, string $admin_label ) — $name as display_admin_label() made it, * $admin_label as registered. An add-on names its own routes here. */ const LABEL_FILTER = 'careback_route_admin_label'; /** * Filter that receives the state of every route for one order line: * ( array $routes, \WC_Order $order, int $line_item_id, Settings $settings, * Requests_Repository|null $requests ). */ const LINE_FILTER = 'careback_line_routes'; /** * Filter that receives the date the windows of one order line run from: * ( int $timestamp, \WC_Order $order, int $line_item_id, * Requests_Repository|null $requests ). */ const LINE_DATE_FILTER = 'careback_line_reference_date'; /** Filter that receives the request types that hold no units. */ const NON_CONSUMING_FILTER = 'careback_non_consuming_types'; /** * Types that hold no units whatever is registered. * * Data safety: a guarantee claim is about an item the customer keeps, and * rows of that type exist on shops that filed them. Were they to start * holding units because no registration said otherwise, every one of them * would close the withdrawal and the return on its line. */ const NON_CONSUMING_DEFAULT = array( 'warranty' ); /** Every property with the value a registration that leaves it out gets. */ const PROPERTIES = array( 'admin_label' => '', 'label_key' => '', 'hint_key' => '', 'submit_key' => 'send', 'reason_key' => 'need_reason', 'comment_key' => 'need_comment', 'can_be_statutory' => true, 'consumes_units' => true, 'reason_mode' => 'optional', 'reason_mode_fixed' => false, 'comment_with_reason' => false, 'window_reason' => true, 'offered' => null, 'state' => null, 'position' => 1000, 'review_key' => 'review_title', 'review_type_key' => '', 'policy_consent' => false, 'cost_key' => '', ); /** * Properties of the free part's own routes that a registration cannot * change: whether it carries a statutory declaration and holds units, how * the reason is asked (the withdrawal is reason-free by law), when reason * and comment are required, and the rules that open it on an order. A * callback that returns either route with other values, or without it, * gets them back as defaults() has them. */ const CORE_PROPERTIES = array( 'can_be_statutory', 'consumes_units', 'reason_mode', 'reason_mode_fixed', 'comment_with_reason', 'window_reason', 'offered', 'state', 'policy_consent' ); /** * The routes this plugin registers itself: the statutory withdrawal and * the shop's voluntary return. * * @return array */ public static function defaults() { return array( // Always listed. Reason-free by law, so the reason is optional and the // shop cannot make it a condition of filing. 'withdrawal' => array( 'admin_label' => 'Withdrawal', 'label_key' => 'flow_withdrawal', 'hint_key' => 'flow_withdrawal_hint', 'submit_key' => 'send_withdrawal', 'reason_mode' => 'optional', 'reason_mode_fixed' => true, // Switched off by the shop, with a confirmation, where its // customers have no such right (N-80); on by default. 'offered' => array( __CLASS__, 'withdrawal_offered' ), 'state' => array( __CLASS__, 'withdrawal_state' ), 'position' => 10, 'review_key' => 'review_title_withdrawal', 'review_type_key' => 'review_type_withdrawal', 'policy_consent' => false, 'cost_key' => 'return_cost', ), 'return' => array( 'admin_label' => 'Return', 'label_key' => 'flow_return', 'hint_key' => 'flow_return_hint', 'submit_key' => 'send_return', 'state' => array( __CLASS__, 'return_state' ), 'position' => 20, // The shop's own offer, on its own terms: the consent may be asked. 'review_key' => 'review_title_return', 'review_type_key' => 'review_type_return', 'policy_consent' => true, 'cost_key' => 'return_cost', ), ); } /** * The name an operator reads for a route (step 6, translations): a built-in * name still as registered in the reader's language, a name a callback of * `careback_routes` changed as written, a route no registration names as its * raw value; then `careback_route_admin_label`, where an add-on names its * own. The `type` column keeps the key. * * @param string $route Route key. * @return string */ public static function display_admin_label( $route ) { $route = (string) $route; $all = self::all(); $label = isset( $all[ $route ] ) ? (string) $all[ $route ]['admin_label'] : $route; $own = array( 'withdrawal' => array( 'Withdrawal', _x( 'Withdrawal', 'request type', 'careback' ) ), 'return' => array( 'Return', _x( 'Return', 'request type', 'careback' ) ), ); $name = isset( $own[ $route ] ) && $own[ $route ][0] === $label ? $own[ $route ][1] : $label; return (string) apply_filters( self::LABEL_FILTER, $name, $route, $label ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_route_admin_label, the final prefix. } /** * Every registered route with every property set, in portal order. * * @return array */ public static function all() { return Hook_Cache::get( self::FILTER, array( __CLASS__, 'build' ) ); } /** * Builds the list: the free part's routes through `careback_routes`, the * free part's own put back with their core properties, every property * completed, in portal order. Use all(), which keeps the result. * * @return array */ public static function build() { // Registry::build(), shared with the status registry. return Registry::build( self::FILTER, self::defaults(), static function ( $key, array $definition ) { return self::normalise( $key, $definition ); }, self::CORE_PROPERTIES ); } /** * Keys of the registered routes, in portal order. * * @return string[] */ public static function keys() { return array_keys( self::all() ); } /** * True when a registration names the route. * * @param string $route Route key. * @return bool */ public static function exists( $route ) { return array_key_exists( (string) $route, self::all() ); } /** * Properties of one registered route, or null. * * @param string $route Route key. * @return array|null */ public static function get( $route ) { $all = self::all(); return isset( $all[ (string) $route ] ) ? $all[ (string) $route ] : null; } /** * One property of a route, or the default of that property when the route * is not registered. * * @param string $route Route key. * @param string $property Property name. * @return mixed */ public static function property( $route, $property ) { $definition = self::get( $route ); if ( null !== $definition && array_key_exists( $property, $definition ) ) { return $definition[ $property ]; } return array_key_exists( $property, self::PROPERTIES ) ? self::PROPERTIES[ $property ] : null; } /** * True while a route can be filed at all: registered, and switched on where * it has a switch. * * @param string $route Route key. * @param Settings $settings Plugin settings. * @return bool */ public static function offered( $route, Settings $settings ) { $definition = self::get( $route ); if ( null === $definition ) { return false; } return ! is_callable( $definition['offered'] ) || (bool) call_user_func( $definition['offered'], $settings ); } /** * True when a request of this type may carry a statutory declaration. * * A type no registration names keeps the answer it always had: anything * that holds units can, anything that holds none cannot. * * @param string $type Request type. * @return bool */ public static function can_be_statutory( $type ) { $definition = self::get( $type ); if ( null !== $definition ) { return (bool) $definition['can_be_statutory']; } return ! in_array( (string) $type, self::non_consuming_types(), true ); } /** * Request types that hold no units of the order: those registered as such, * plus the data-safety default, then filtered. * * @return string[] */ public static function non_consuming_types() { $types = self::NON_CONSUMING_DEFAULT; foreach ( self::all() as $key => $definition ) { if ( ! $definition['consumes_units'] && ! in_array( $key, $types, true ) ) { $types[] = $key; } } $types = apply_filters( self::NON_CONSUMING_FILTER, $types ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_non_consuming_types, the final prefix. return array_values( array_unique( array_map( 'strval', (array) $types ) ) ); } /** * True while some route that holds no units can be filed, so a line whose * units are all taken still has something to offer. * * @param Settings $settings Plugin settings. * @return bool */ public static function unbounded_offered( Settings $settings ) { foreach ( self::all() as $key => $definition ) { if ( ! $definition['consumes_units'] && self::offered( $key, $settings ) ) { return true; } } return false; } /** * State of every registered route on this order, in portal order. * * @param \WC_Order $order Order. * @param Settings $settings Plugin settings. * @return array */ public static function states( \WC_Order $order, Settings $settings ) { $out = array(); foreach ( self::all() as $key => $definition ) { $state = is_callable( $definition['state'] ) ? call_user_func( $definition['state'], $order, $settings ) : array(); $state = is_array( $state ) ? $state : array(); $out[ $key ] = array( 'available' => ! empty( $state['available'] ), 'reason' => isset( $state['reason'] ) ? (string) $state['reason'] : '', 'visible' => ! array_key_exists( 'visible', $state ) || ! empty( $state['visible'] ), ); } return $out; } /** * The route list the portal script renders, in order: key, the three * texts, the two messages, and the facts the script decides on. * * @param Settings $settings Plugin settings. * @return array */ public static function for_portal( Settings $settings ) { $texts = Texts::all( $settings ); $text = static function ( $key ) use ( $texts ) { return isset( $texts[ $key ] ) ? (string) $texts[ $key ] : ''; }; $out = array(); // With statutory withdrawal off the return is the customer's own way back, // shown under a name of its own with the shop's period (N-80). $single_return = ! self::withdrawal_offered( $settings ); foreach ( self::all() as $key => $definition ) { // Switched off, the statutory route stays in the list (the script reads // it as not offered) without a word of its own: the page names no right // the shop's customers do not have. $words = $single_return && 'withdrawal' === $key ? static function () { return ''; } : $text; $out[] = array( 'value' => $key, 'label' => $single_return && 'return' === $key ? $text( 'return_single_title' ) : $words( $definition['label_key'] ), 'hint' => $single_return && 'return' === $key ? Texts::format_number( 'return_single_hint', $settings, (int) $settings->get( 'return_window_days', 30 ) ) : $words( $definition['hint_key'] ), 'send' => $words( $definition['submit_key'] ), 'reasonRequired' => $words( $definition['reason_key'] ), 'commentRequired' => $words( $definition['comment_key'] ), 'offered' => self::offered( $key, $settings ), 'commentWithReason' => (bool) $definition['comment_with_reason'], // A unit another request holds stays tickable on the statutory // route, which reaches over voluntary claims, and on a route that // takes no unit with it. 'claimedOpen' => 'withdrawal' === $key || ! $definition['consumes_units'], // The check step (N-04): its heading and what its request row says. 'review' => $words( $definition['review_key'] ), 'reviewType' => '' !== $definition['review_type_key'] ? $words( $definition['review_type_key'] ) : $words( $definition['label_key'] ), // Whether the consent with the returns policy goes with it (N-12), // while the shop asks for it (CarebackData.consent). 'policyConsent' => (bool) $definition['policy_consent'], // Who pays for sending the goods back, as the check step says it // (N-116); '' where the route says nothing on it. 'returnCost' => '' !== $definition['cost_key'] ? $words( $definition['cost_key'] ) : '', ); } return $out; } /** * The statutory withdrawal on this order: open while its window is. * * @param \WC_Order $order Order. * @param Settings $settings Plugin settings. * @return array{available:bool,reason:string,visible:bool} */ public static function withdrawal_state( \WC_Order $order, Settings $settings ) { // Switched off (N-80): neither open nor shown, on any order. if ( ! self::withdrawal_offered( $settings ) ) { return array( 'available' => false, 'reason' => '', 'visible' => false, ); } $open = Order_Lookup::window_open( $order, $settings->get( 'statutory_window_days', 14 ) ); return array( 'available' => $open, 'reason' => $open ? '' : Texts::get( 'flow_withdrawal_closed', $settings ), 'visible' => true, ); } /** * The voluntary return on this order: open while its own window is, and not * rendered while the statutory route is still open. With statutory * withdrawal switched off it is the customer's one way back, shown from the * first day (N-80). * * @param \WC_Order $order Order. * @param Settings $settings Plugin settings. * @return array{available:bool,reason:string,visible:bool} */ public static function return_state( \WC_Order $order, Settings $settings ) { $withdrawal_open = self::withdrawal_offered( $settings ) && Order_Lookup::window_open( $order, $settings->get( 'statutory_window_days', 14 ) ); $open = Order_Lookup::window_open( $order, $settings->get( 'return_window_days', 30 ) ); return array( 'available' => $open, 'reason' => $open ? '' : Texts::get( 'flow_return_closed', $settings ), 'visible' => ! $withdrawal_open, ); } /** * True while statutory withdrawal is on (N-80): the `offered` rule of the * withdrawal route. An add-on asks Routes::offered( 'withdrawal', … ). * * @param Settings $settings Plugin settings. * @return bool */ public static function withdrawal_offered( Settings $settings ) { return ! empty( $settings->get( 'withdrawal_enabled', 1 ) ); } /** * One definition with every property present and typed. * * @param string $key Route key. * @param array $definition Properties as registered. * @return array */ private static function normalise( $key, array $definition ) { $out = array_merge( self::PROPERTIES, array_intersect_key( $definition, self::PROPERTIES ) ); $out['key'] = $key; $out['admin_label'] = '' !== (string) $out['admin_label'] ? (string) $out['admin_label'] : $key; $out['reason_mode'] = in_array( $out['reason_mode'], array( 'off', 'optional', 'required' ), true ) ? $out['reason_mode'] : 'optional'; $out['position'] = (int) $out['position']; foreach ( array( 'can_be_statutory', 'consumes_units', 'reason_mode_fixed', 'comment_with_reason', 'window_reason', 'policy_consent' ) as $flag ) { $out[ $flag ] = (bool) $out[ $flag ]; } foreach ( array( 'label_key', 'hint_key', 'submit_key', 'reason_key', 'comment_key', 'review_key', 'review_type_key', 'cost_key' ) as $text ) { $out[ $text ] = (string) $out[ $text ]; } return $out; } }