1, 'return' => 'objects', 'meta_key' => '_order_number', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- the number a numbering plugin shows the customer; one order asked for. 'meta_value' => $raw, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- the number the customer typed. ) ); } catch ( \Throwable $e ) { // Throwable, not Exception: a data store that answers with a fatal // Error rather than an exception would otherwise take the whole // portal down on a lookup, which is the one page a customer needs // when something has already gone wrong. $by_meta = array(); } if ( ! empty( $by_meta ) && $by_meta[0] instanceof \WC_Order ) { return $by_meta[0]; } // Then the internal id, and only when the input is that id — digits and // nothing else — and the order agrees that this is the number it shows // to the customer. A shop with no numbering plugin has both the same. if ( '' === $raw || ! ctype_digit( $raw ) ) { return false; } $order = wc_get_order( absint( $raw ) ); if ( $order instanceof \WC_Order && (string) $order->get_order_number() === $raw ) { return $order; } return false; } /** * An order number as the customer typed it, without the "#" in front. * * WooCommerce's own e-mails print "Order #1234", and a customer copies what * they see. The sign is decoration, not part of any number, so one leading * "#" and the spaces around it are taken off — nothing else: "abc1234" stays * "abc1234". Applied before any lookup or counter, so every way of writing * the same number costs the same and counts against the same allowance. * * @param string $order_number As typed. * @return string */ public static function clean_number( $order_number ) { $number = (string) $order_number; // \p{Zs} covers the no-break space an e-mail client puts after the sign. $trimmed = preg_replace( '/^[\s\p{Zs}]+|[\s\p{Zs}]+$/u', '', $number ); $number = null === $trimmed ? trim( $number ) : $trimmed; if ( '' !== $number && '#' === $number[0] ) { $rest = substr( $number, 1 ); $after = preg_replace( '/^[\s\p{Zs}]+/u', '', $rest ); $number = null === $after ? ltrim( $rest ) : $after; } return $number; } /** * Statuses no new request is ever filed from, whatever the shop mapped them * to for the tracking page: no paid order, or one already undone. */ const NEVER_ELIGIBLE_STATUSES = array( 'pending', 'failed', 'cancelled', 'refunded', 'checkout-draft' ); /** * Order statuses a new request can be filed from. * * Processing, on hold and completed — an order the customer has, or is about * to have — plus every status the shop treats as on its way or delivered: * the one the delivery dates fall back on, and any status mapped for the * tracking page to a stage from "Preparing your order" onward (a withdrawal * may be declared before the goods arrive). Pending payment, failed, * cancelled, refunded and a checkout draft never are: they are a case for * the shop, not for the form. Requests already filed are not affected. * * @param Settings $settings Supplies the tracking page's status map. * @param \WC_Order|null $order Order being asked about, handed to the filter. * @return string[] Status slugs without the "wc-" prefix. */ public static function eligible_statuses( Settings $settings, $order = null ) { // With the status the shop chose as "delivered" (N-76). $statuses = array_merge( array( 'processing', 'on-hold', 'completed' ), self::DELIVERED_STATUSES, array( Periods::delivered_status( $settings ) ) ); $from = Track_Stage_Policy::index( 'order', 'order_preparing' ); foreach ( Track_Stage_Policy::order_map( $settings ) as $status => $stage ) { if ( Track_Stage_Policy::index( 'order', (string) $stage ) >= $from ) { $statuses[] = (string) $status; } } $statuses = array_diff( $statuses, self::NEVER_ELIGIBLE_STATUSES ); /** * Filters the order statuses a return, withdrawal or claim can be filed from. * * @param string[] $statuses Status slugs without the "wc-" prefix. * @param \WC_Order|null $order Order being asked about. */ $statuses = apply_filters( 'careback_eligible_order_statuses', array_values( array_unique( $statuses ) ), $order ); return array_map( 'strval', (array) $statuses ); } /** * True when a new request can be filed for the order in the status it is in now. * * @param \WC_Order $order Order. * @param Settings $settings Plugin settings. * @return bool */ public static function status_eligible( \WC_Order $order, Settings $settings ) { return in_array( (string) $order->get_status(), self::eligible_statuses( $settings, $order ), true ); } public static function email_matches( \WC_Order $order, $email ) { return Hash::equals( Hash::email( $order->get_billing_email() ), Hash::email( $email ) ); } /** * Runs the same hashing work as a real email comparison and always reports no * match. Called when no order was found, so a missing order costs the same * time as a wrong email and the response cannot be timed apart. */ public static function absorb_email_check( $email ) { Hash::equals( Hash::email( 'careback-no-such-order' ), Hash::email( $email ) ); return false; } /** * Minimal, safe projection of the order for the portal. * No addresses, totals, payment or customer records. */ /** * Returnable lines, optionally annotated with how many units are still free. * * The per-unit breakdown is computed for display only and never stored: three * identical items in a box are not numbered, so an index would record a * fact nobody can verify. */ public static function items( \WC_Order $order, ?Requests_Repository $requests = null, ?Settings $settings = null ) { $items = array(); $consumed = $requests ? $requests->consumed_units( $order->get_id() ) : array(); $claims = $requests ? $requests->claims_by_line( $order->get_id() ) : array(); // While the statutory window is open, a unit held under a voluntary offer // is still open to a withdrawal. $statutory_open = $settings instanceof Settings && Routes::withdrawal_offered( $settings ) && self::window_open( $order, $settings->get( 'statutory_window_days', 14 ) ); foreach ( $order->get_items() as $item_id => $item ) { $id = (int) $item_id; $quantity = (int) $item->get_quantity(); $claimed = isset( $consumed[ $id ] ) ? min( $quantity, (int) $consumed[ $id ] ) : 0; // The name on one line, and its name and details apart (N-16): the // form draws each detail on a line of its own under the name. $parts = self::item_parts( $item ); $entry = array( 'line_item_id' => $id, 'name' => empty( $parts['details'] ) ? $parts['title'] : $parts['title'] . ' (' . implode( ', ', $parts['details'] ) . ')', 'title' => $parts['title'], 'details' => $parts['details'], 'quantity' => $quantity, 'claimed' => $claimed, 'available' => max( 0, $quantity - $claimed ), 'windowEndsAt' => 0, ); // Whether the statutory window reaches this line at all: a line the // window callbacks take out of it holds no unit a withdrawal may // reach over. $line_statutory = true; if ( $settings instanceof Settings ) { $window = self::line_window( $order, $id, $settings, $requests ); $entry['windowEndsAt'] = $window['ends_at']; $line_statutory = $window['statutory']; } if ( $requests ) { $entry['units'] = self::unit_states( $id, $quantity, $claimed, isset( $claims[ $id ] ) ? $claims[ $id ] : array(), $statutory_open && $line_statutory ); } $items[] = $entry; } return $items; } /** * One entry per purchased unit: the first N are marked as claimed, where N is * how many units existing requests hold. Which physical unit that is remains * unknown, so the index is positional and display-only. * * `withdrawable` marks a claimed unit the statutory route may still be filed * on: the claim holding it carries no statutory declaration of its own, the * statutory window is open, and the line still has room under the units its * statutory claims already hold. No more units are marked than * `Submission::create()` accepts on that route. * * @param bool $statutory_open Statutory window open on this line. */ private static function unit_states( $line_item_id, $quantity, $claimed, array $claims, $statutory_open = false ) { $units = array(); $refs = array(); $open = array(); $held = 0; foreach ( $claims as $claim ) { if ( ! empty( $claim['statutory'] ) ) { $held += (int) $claim['quantity']; } for ( $i = 0; $i < (int) $claim['quantity']; $i++ ) { $refs[] = (int) $claim['id']; $open[] = empty( $claim['statutory'] ); } } $room = max( 0, (int) $quantity - $held ); for ( $index = 1; $index <= $quantity; $index++ ) { $is_claimed = $index <= $claimed; $is_open = $is_claimed && $statutory_open && $room > 0 && ! empty( $open[ $index - 1 ] ); if ( $is_open ) { $room--; } $units[] = array( 'key' => $line_item_id . ':' . $index, 'claimed' => $is_claimed, 'request' => $is_claimed && isset( $refs[ $index - 1 ] ) ? $refs[ $index - 1 ] : 0, 'withdrawable' => $is_open, ); } return $units; } /** * Display label for a line item on one line: the item's name with its * details in brackets (item_parts()), e.g. "Bag (Color: Red, Size: M)", or * the plain name while the shop shows no details. Kept on one line where a * line is all there is: the request as stored, the admin lists, the notes. * * @param \WC_Order_Item_Product|\WC_Order_Item $item Order line item. * @return string */ public static function item_label( $item ) { $parts = self::item_parts( $item ); if ( empty( $parts['details'] ) ) { return $parts['title']; } return $parts['title'] . ' (' . implode( ', ', $parts['details'] ) . ')'; } /** * A line item as its name and its details, each a line of its own ("Size: * M", "Engraving: Anna"), driven entirely from Settings so store admins need * no code: * * - the name: the parent product's clean name for a variation, the * product's for any other, the line's own when the product is gone; * - WooCommerce's details (`variation_display`, N-16): the line's fields as * the order shows them — the variation's attributes among them — hidden * meta never; none while the product is gone; * - the shop's extra item details (`item_fields`, X-04), added to them or * in place of WooCommerce's line for the same field, then the * `careback_item_details` filter (Item_Fields::apply()). * * @param \WC_Order_Item_Product|\WC_Order_Item $item Order line item. * @return array{title:string,details:string[]} */ public static function item_parts( $item ) { $fallback = wp_strip_all_tags( $item->get_name() ); if ( ! is_callable( array( $item, 'get_product' ) ) ) { return array( 'title' => $fallback, 'details' => array(), ); } $settings = new Settings(); $product = $item->get_product(); $name = $fallback; $woo = array(); if ( $product ) { // The clean base name lives on the parent for a variation. $parent_id = ( is_callable( array( $product, 'get_parent_id' ) ) && $product->get_parent_id() ) ? (int) $product->get_parent_id() : (int) $product->get_id(); $parent = wc_get_product( $parent_id ); $name = $parent ? wp_strip_all_tags( $parent->get_name() ) : $fallback; $name = '' !== $name ? $name : $fallback; if ( (int) $settings->get( 'variation_display', 0 ) ) { $woo = self::woo_details( $item ); } } return Item_Fields::apply( $item, $name, $woo, $settings ); } /** * The details of one line of an order, by its id: what item_parts() makes * of it, or the stored name alone when the order or the line is gone. * * @param \WC_Order|null $order Order. * @param int $line_item_id Line of the order. * @param string $name The name stored with the request. * @return array{title:string,details:string[]} */ public static function line_parts( $order, $line_item_id, $name ) { $item = $order instanceof \WC_Order ? $order->get_item( (int) $line_item_id ) : null; if ( ! $item instanceof \WC_Order_Item_Product ) { return array( 'title' => (string) $name, 'details' => array(), ); } return self::item_parts( $item ); } /** * WooCommerce's details of a line: the native variation attributes and any * other field the line carries, read from its formatted meta, as [ field, * "Key: Value" ] pairs in the order WooCommerce keeps them. Hidden meta — a * key starting with "_", such as the _reduced_stock WooCommerce writes on a * line whose stock it keeps — never is one. * * @param \WC_Order_Item $item Order line item. * @return array e.g. [ [ "color", "Color: Red" ], [ "size", "Size: M" ] ]. */ protected static function woo_details( $item ) { $pairs = array(); if ( is_callable( array( $item, 'get_formatted_meta_data' ) ) ) { foreach ( (array) $item->get_formatted_meta_data( '_', true ) as $meta ) { $field = is_object( $meta ) && isset( $meta->key ) ? (string) $meta->key : ''; $key = is_object( $meta ) && isset( $meta->display_key ) ? $meta->display_key : ''; $value = is_object( $meta ) && isset( $meta->display_value ) ? $meta->display_value : ''; $key = trim( wp_strip_all_tags( (string) $key ) ); $value = trim( wp_strip_all_tags( (string) $value ) ); if ( '' !== $key && '' !== $value ) { $pairs[] = array( $field, $key . ': ' . $value ); } } } return $pairs; } /** * Reference date for advisory classification: when the customer most likely * received the goods. The statutory window runs from receipt, not from the * order date, so the latest known fulfilment timestamp is used and any * uncertainty is resolved in the customer's favour. */ /** * Statuses that evidence the customer has the goods. Delivery is what starts * every window, so a request is only accepted once the order reached one of * these — with a recorded delivery timestamp taking precedence when present. */ const DELIVERED_STATUSES = array( 'completed' ); /** * Statuses where the parcel came back to us. Empty by default: no order status * carries this meaning across stores, so it is set per site. */ public static function returned_to_sender_statuses() { return (array) apply_filters( 'careback_returned_to_sender_statuses', array() ); } /** * Timestamp of the actual handover, or 0 when it was never recorded. * The meta key is filterable so a store can point this at whatever field its * carrier integration writes. */ public static function delivered_at( \WC_Order $order ) { $recorded = self::recorded_delivery( $order ); if ( $recorded ) { return $recorded; } // A shop with no delivery integration has nothing else to go on, so the // day the order got the status the shop chose as "delivered" (Completed // unless it chose another, N-76) stands in for a handover. A shop whose // adapter sees every outbound shipment turns it off, because an order // completed by hand — or by a payment plugin — starts a statutory window // on a day nothing was delivered. if ( ! apply_filters( 'careback_use_completed_fallback', true, $order ) ) { return 0; } return Periods::status_reached_at( $order, new Settings() ); } /** * The delivery date a delivery service or another plugin recorded on the * order (the meta key is filterable), or 0. * * @param \WC_Order $order Order. * @return int Unix timestamp. */ private static function recorded_delivery( \WC_Order $order ) { $key = (string) apply_filters( 'careback_delivered_at_meta_key', '_careback_delivered_at' ); $recorded = $order->get_meta( $key ); if ( $recorded ) { $time = strtotime( (string) $recorded ); if ( $time ) { return (int) $time; } } return 0; } /** * Timestamp the parcel came back to the shop undelivered, or 0. * * Meta-backed like the handover above, with the order-status list kept as a * fallback so no site loses the behaviour it had. */ public static function returned_to_sender_at( \WC_Order $order ) { $key = (string) apply_filters( 'careback_returned_to_sender_at_meta_key', '_careback_returned_to_sender_at' ); $recorded = $order->get_meta( $key ); if ( $recorded ) { $time = strtotime( (string) $recorded ); if ( $time ) { return $time; } } return in_array( $order->get_status(), self::returned_to_sender_statuses(), true ) ? 1 : 0; } /** * Delivery state of the order, for display and for recording on the request. * * @return string One of: delivered, returned_to_sender, in_transit. */ public static function delivery_state( \WC_Order $order ) { $delivered = self::delivered_at( $order ); $returned = self::returned_to_sender_at( $order ); // A comparison, not a precedence. A parcel can be delivered and later come // back — refused at the door, or a second attempt after a first succeeded — // and whichever event is later describes where the goods are now. Asked // before the delivered test, because an order completed early and then // returned by the carrier used to read as delivered for good. if ( $returned && ( ! $delivered || $returned >= $delivered ) ) { return 'returned_to_sender'; } if ( $delivered ) { return 'delivered'; } // The order-status list is the same stand-in as the completed date, and it // is switched off by the same filter. A shop whose adapter sees every // outbound shipment would otherwise be told there is no handover date and // that the order is delivered, in the same breath. if ( apply_filters( 'careback_use_completed_fallback', true, $order ) && $order->get_status() === Periods::delivered_status( new Settings() ) ) { return 'delivered'; } return 'in_transit'; } /** Delivery state after the site has had its say. */ public static function delivery_state_filtered( \WC_Order $order ) { return (string) apply_filters( 'careback_delivery_state', self::delivery_state( $order ), $order ); } /** * Start of the statutory window: the latest recorded handover. Returns 0 while * any part of the order has no handover date, so the window has not started. */ public static function withdrawal_start( \WC_Order $order ) { // A recorded delivery date counts as it is; the day of the status gets // the shop's delivery buffer, for a shop that sets it when it ships (N-76). $recorded = self::recorded_delivery( $order ); if ( $recorded ) { return $recorded; } $reached = self::delivered_at( $order ); return $reached ? $reached + Periods::buffer_days( new Settings() ) * DAY_IN_SECONDS : 0; } /** * Reference date for advisory classification. Returns 0 when no handover is * recorded, so callers treat the window as not yet started. */ public static function reference_date( \WC_Order $order ) { return self::withdrawal_start( $order ); } /** * True when more than $window_days have passed since handover. Returns false * while no handover is recorded. Decides only whether a reason may be asked * for; a request is never refused on time. */ public static function past_withdrawal_window( \WC_Order $order, $window_days ) { $start = self::withdrawal_start( $order ); if ( ! $start ) { return false; } $days = (int) floor( ( time() - $start ) / DAY_IN_SECONDS ); return $days > (int) $window_days; } /** * True while $window_days have not elapsed since handover, counted in whole * days. Returns true when no handover is recorded. */ public static function window_open( \WC_Order $order, $window_days ) { $start = self::reference_date( $order ); if ( ! $start ) { return true; } $days = (int) floor( ( time() - $start ) / DAY_IN_SECONDS ); return $days <= (int) $window_days; } /** * Start of the windows that apply to one order line. * * The order's own handover, unless a `careback_line_reference_date` callback * knows better for this line — a line carrying a replacement, measured from * the day the replacement reached the customer. Such a callback answers 0 * when it knows the line has no start date yet. * * @param int $line_item_id Line of the order. * @param Requests_Repository|null $requests Handed to the callbacks. * @return int Unix timestamp, or 0 when no start date is known. */ public static function line_reference_date( \WC_Order $order, $line_item_id, ?Requests_Repository $requests = null ) { return (int) apply_filters( Routes::LINE_DATE_FILTER, self::reference_date( $order ), $order, (int) $line_item_id, $requests ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_line_reference_date, the final prefix. } /** * State of every route for one order line. * * The order-level routes, unless a `careback_line_routes` callback answers * differently for this line. A line carrying a replacement * (REPLACEMENT_LINE_META) is handed to the callbacks closed on every route: * nothing is offered on it unless one of them opens it. * * @param int $line_item_id Line of the order. * @param Requests_Repository|null $requests Handed to the callbacks. * @return array */ public static function line_routes( \WC_Order $order, $line_item_id, Settings $settings, ?Requests_Repository $requests = null ) { $routes = self::routes( $order, $settings ); if ( self::is_replacement_line( $order, $line_item_id ) ) { foreach ( array_keys( $routes ) as $route ) { // Closed, with the order's own reason where it has one, and shown // as the order shows it: one line cannot reveal a route. $routes[ $route ]['available'] = false; } } return (array) apply_filters( Routes::LINE_FILTER, $routes, $order, (int) $line_item_id, $settings, $requests ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_line_routes, the final prefix. } /** * True when the order line carries the replacement mark * (REPLACEMENT_LINE_META). * * @param \WC_Order $order Order. * @param int $line_item_id Line of the order. * @return bool */ public static function is_replacement_line( \WC_Order $order, $line_item_id ) { $items = $order->get_items(); $id = (int) $line_item_id; return isset( $items[ $id ] ) && (bool) $items[ $id ]->get_meta( self::REPLACEMENT_LINE_META ); } /** * The windows of one order line. * * `starts_at` is the line's reference date (line_reference_date()); * `ends_at` the end of the longest window the line may still be filed on — * the longer of the two refund windows, counted from that date and * including its last whole day, or 0 while the line has no start date; and * `statutory` whether the statutory withdrawal window applies to the line * at all. A `careback_line_window` callback may answer differently for a * line it knows more about. A line carrying a replacement * (REPLACEMENT_LINE_META) is handed to the callbacks with no window * (`ends_at` 0) and outside the statutory one. * * @param \WC_Order $order Order. * @param int $line_item_id Line of the order. * @param Settings $settings Plugin settings. * @param Requests_Repository|null $requests Handed to the callbacks. * @return array{starts_at:int,ends_at:int,statutory:bool} */ public static function line_window( \WC_Order $order, $line_item_id, Settings $settings, ?Requests_Repository $requests = null ) { $start = self::line_reference_date( $order, $line_item_id, $requests ); // With statutory withdrawal switched off (N-80) the return period is the // line's only window, and no line is inside a statutory one. $statutory = Routes::withdrawal_offered( $settings ); $days = $statutory ? max( (int) $settings->get( 'statutory_window_days', 14 ), (int) $settings->get( 'return_window_days', 30 ) ) : (int) $settings->get( 'return_window_days', 30 ); $window = array( 'starts_at' => $start, // The window includes its last whole day, matching window_open(). 'ends_at' => $start ? $start + ( ( $days + 1 ) * DAY_IN_SECONDS ) : 0, 'statutory' => $statutory, ); if ( self::is_replacement_line( $order, $line_item_id ) ) { $window['ends_at'] = 0; $window['statutory'] = false; } /** * Filters the windows of one order line. * * @param array $window { starts_at, ends_at, statutory }. * @param \WC_Order $order Order. * @param int $line_item_id Line of the order. * @param Settings $settings Plugin settings. * @param Requests_Repository|null $requests Supplies the order's requests. */ $window = (array) apply_filters( self::LINE_WINDOW_FILTER, $window, $order, (int) $line_item_id, $settings, $requests ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_line_window, the final prefix. return array( 'starts_at' => isset( $window['starts_at'] ) ? (int) $window['starts_at'] : 0, 'ends_at' => isset( $window['ends_at'] ) ? (int) $window['ends_at'] : 0, 'statutory' => ! empty( $window['statutory'] ), ); } /** * End of the statutory withdrawal window on one order line, or 0 when that * window does not apply to it. * * Returns 0 for a line the order does not carry, for a line the window * callbacks take out of the statutory window (line_window()), for an order * with no recorded handover, and once the window has elapsed. * * @param int $line_item_id Line of the order. * @return int Unix timestamp. */ public static function statutory_window_ends_at( \WC_Order $order, $line_item_id, Settings $settings ) { $items = $order->get_items(); $id = (int) $line_item_id; if ( ! isset( $items[ $id ] ) || ! self::line_window( $order, $id, $settings )['statutory'] ) { return 0; } $start = self::withdrawal_start( $order ); if ( ! $start ) { return 0; } $days = (int) $settings->get( 'statutory_window_days', 14 ); if ( ! self::window_open( $order, $days ) ) { return 0; } // The window includes its last whole day, matching window_open(). return $start + ( ( $days + 1 ) * DAY_IN_SECONDS ); } /** * State of every route for this order. Every registered route is present. * * `reason` carries the merchant-editable text for a closed route and is empty * while the route is open. `visible` says whether the portal renders the route * at all; an invisible route is still accepted by the server. Each route * answers for itself through its registration (Routes). * * @return array */ public static function routes( \WC_Order $order, Settings $settings ) { return Routes::states( $order, $settings ); } /** * State of every route across the order, a route counting as available when at * least one line still offers it. Lines whose every unit is already claimed do * not contribute, so a fully claimed order offers nothing. * * A line whose free units are gone but whose claims carry no statutory * declaration still contributes the statutory route, and only that one. * * The reason shown for a closed route is the order-level one; the per-line * detail travels with the items. * * @param Requests_Repository|null $requests Supplies claims and originating requests. * @return array */ public static function combined_routes( \WC_Order $order, Settings $settings, ?Requests_Repository $requests = null ) { $order_routes = self::routes( $order, $settings ); $keys = array_keys( $order_routes ); $holds_none = Routes::non_consuming_types(); $unbounded = Routes::unbounded_offered( $settings ); $consumed = $requests ? $requests->consumed_units( $order->get_id() ) : array(); $statutory = $requests ? $requests->statutory_units( $order->get_id() ) : array(); $combined = array(); $shown = array(); $seen = false; foreach ( $order->get_items() as $item_id => $item ) { $id = (int) $item_id; $quantity = (int) $item->get_quantity(); $claimed = isset( $consumed[ $id ] ) ? (int) $consumed[ $id ] : 0; $available = $quantity - $claimed; $held = isset( $statutory[ $id ] ) ? (int) $statutory[ $id ] : 0; $reachable = $quantity - $held; // A line every earlier request already covers still reports a defect: // a route that takes no unit away from anyone (see // Routes::non_consuming_types()) has nothing left to run out of. if ( $available < 1 && $reachable < 1 && ! $unbounded ) { continue; } $line = self::line_routes( $order, $id, $settings, $requests ); $seen = true; foreach ( $keys as $route ) { // The statutory route reaches into units a voluntary request holds, // but only while some remain reachable — a line every statutory // declaration already covers has nothing left for it. Before the // guarantee route existed such a line was skipped entirely, which // hid the difference; now the line is kept and the test has to be // made properly. $offered = ! empty( $line[ $route ]['available'] ) && ( $available > 0 || ( 'withdrawal' === $route && $reachable > 0 ) || in_array( $route, $holds_none, true ) ); $combined[ $route ] = ! empty( $combined[ $route ] ) || $offered; $shown[ $route ] = ! empty( $shown[ $route ] ) || ! empty( $line[ $route ]['visible'] ); } } if ( ! $seen ) { return $order_routes; } $out = array(); foreach ( $keys as $route ) { $open = ! empty( $combined[ $route ] ); $out[ $route ] = array( 'available' => $open, 'reason' => $open ? '' : $order_routes[ $route ]['reason'], 'visible' => ! empty( $shown[ $route ] ), ); } return $out; } }