label. Arguments: array $orderings. */ const ORDERINGS = 'careback_list_orderings'; /** Action: in the toolbar, after the Filter button. Arguments: array $query, Settings $settings. */ const TOOLBAR = 'careback_requests_toolbar'; /** Filter: the list columns, key => definition. Arguments: array $columns, Settings $settings. */ const COLUMNS = 'careback_request_list_columns'; /** Filter: the badges of the Type cell, key => definition. Arguments: array $badges, array $row, Settings $settings. */ const BADGES = 'careback_request_list_badges'; /** Action: in the status form of a row, before Save. Arguments: array $row, Settings $settings. */ const STATUS_FIELDS = 'careback_request_status_fields'; /** Filter: the blocks of the request card, key => definition. Arguments: array $blocks, array $row, Settings $settings. */ const DETAIL_BLOCKS = 'careback_request_detail_blocks'; /** Filter: the name of one history entry. Arguments: string $label, array $event, array $row. */ const HISTORY_LABEL = 'careback_history_entry_label'; /** Filter: names of stored resolution values, value => label. Arguments: array $labels. */ const RESOLUTION_LABELS = 'careback_resolution_labels'; /** Filter: confirmations a status change carries, gate => position. Arguments: array $gates, array $row, string $status, array $args. */ const GATES = 'careback_confirmation_gates'; /** Filter: whether an answer given at a gate is accepted. Arguments: bool $accepted, string $gate, array $row, array $answers. */ const GATE_ACCEPTED = 'careback_confirmation_accepted'; /** Filter: the message for an answer not accepted. Arguments: string $message, string $gate, array $row. */ const GATE_ERROR = 'careback_confirmation_gate_error'; /** Filter: the explanations of the confirmation step, gate => definition. Arguments: array $sections, array $gates, array $row, string $status. */ const GATE_SECTIONS = 'careback_confirm_panel_sections'; /** Filter: the fields of the confirmation step, gate => definition. Arguments: array $fields, array $gates, array $row, string $status. */ const GATE_FIELDS = 'careback_confirm_panel_fields'; /** Filter: the answers posted with a status change. Arguments: array $answers, array $gates, array $row. */ const GATE_ANSWERS = 'careback_confirmation_answers'; /** Action: every gate answered, before the status is written. Arguments: array $gates, int $id, array $row, array $answers, string $status. */ const GATE_ANSWERED = 'careback_gate_answered'; /** Action: a status change about to be written. Arguments: int $id, string $status, array $row. */ const STATUS_SAVING = 'careback_status_saving'; /** Filter: where a saved status change sends the operator. Arguments: array $args, int $id, string $status, array $row, array $gates, Settings $settings. */ const STATUS_SAVED_ARGS = 'careback_status_saved_args'; /** Filter: what a declaration recorded by hand can be, route => label. Arguments: array $types, Settings $settings. */ const MANUAL_TYPES = 'careback_manual_entry_types'; /** Filter: the row of a declaration recorded by hand. Arguments: array $data, bool $statutory, \WC_Order $order, Settings $settings. */ const MANUAL_ROW = 'careback_manual_entry_row'; private $requests; private $events; private $settings; /** * @param Requests_Repository $requests The requests table. * @param Events_Repository $events The event log. * @param Settings|null $settings Plugin settings. * @param mixed $legacy Ignored. A fourth argument earlier * versions took; accepted so a caller * that still passes it keeps working. */ public function __construct( Requests_Repository $requests, Events_Repository $events, ?Settings $settings = null, $legacy = null ) { unset( $legacy ); $this->requests = $requests; $this->events = $events; $this->settings = $settings instanceof Settings ? $settings : new Settings(); } public function init() { // The menu is registered by Admin; this page only owns its status action. add_action( 'admin_post_careback_update_status', array( $this, 'update_status' ) ); add_action( 'admin_post_careback_dispatch_proof', array( $this, 'dispatch_proof' ) ); add_action( 'admin_post_careback_manual_entry', array( $this, 'manual_entry' ) ); add_action( 'admin_post_careback_arrival', array( $this, 'arrival' ) ); } /** New (unhandled) requests, used for the menu badge. */ public function new_count() { return (int) $this->requests->count( 'new', '' ); } /** * Every type a stored request can carry: the registered routes, then the * types no route files any more. The filter above the list offers these. * * @return string[] */ public static function types() { return array_values( array_unique( array_merge( Routes::keys(), self::STORED_TYPES ) ) ); } /** * True when the screen may change this request: its status is a registered * one and its type one the screen knows. * * A row can carry a status or a route that no registration names any more, * written while one did. It is shown as it is stored and offered no status * change, because a change the screen cannot validate is one it must not * make (04 §4). */ public static function editable( array $row ) { return Statuses::is_registered( isset( $row['status'] ) ? (string) $row['status'] : '' ) && in_array( isset( $row['type'] ) ? (string) $row['type'] : '', self::types(), true ); } public function render() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } // Display only: the list's filters, search, page and sort, and the result // of the redirect after an action; nothing is changed. // phpcs:disable WordPress.Security.NonceVerification.Recommended // status may arrive as status[]=new&status[]=duplicate (multi-select) or a // legacy single status=new. Normalise to a validated list. $raw_status = isset( $_GET['status'] ) ? map_deep( wp_unslash( $_GET['status'] ), 'sanitize_key' ) : array(); $raw_status = is_array( $raw_status ) ? $raw_status : array( $raw_status ); $status = array_values( array_intersect( array_filter( $raw_status, 'is_string' ), Statuses::keys() ) ); // Read exactly like the status above, and validated against the same kind // of whitelist, so an unknown value narrows nothing rather than reaching // the query. $raw_type = isset( $_GET['type'] ) ? map_deep( wp_unslash( $_GET['type'] ), 'sanitize_key' ) : array(); $raw_type = is_array( $raw_type ) ? $raw_type : array( $raw_type ); $type = array_values( array_intersect( array_filter( $raw_type, 'is_string' ), self::types() ) ); $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $paged = isset( $_GET['paged'] ) ? max( 1, absint( wp_unslash( $_GET['paged'] ) ) ) : 1; $notice = isset( $_GET['careback_notice'] ) ? sanitize_key( wp_unslash( $_GET['careback_notice'] ) ) : ''; $error = isset( $_GET['careback_error'] ) ? sanitize_text_field( wp_unslash( $_GET['careback_error'] ) ) : ''; // A status change held back until the operator answers for it. $confirm_id = isset( $_GET['careback_confirm'] ) ? absint( wp_unslash( $_GET['careback_confirm'] ) ) : 0; $confirm_status = isset( $_GET['careback_confirm_status'] ) ? sanitize_key( wp_unslash( $_GET['careback_confirm_status'] ) ) : ''; // Sort order; anything unrecognised falls back to newest-first. Only an // order the screen offers and the repository knows is ever used. $orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'newest'; $orderings = $this->orderings(); $known = Requests_Repository::orderings(); if ( ! isset( $orderings[ $orderby ], $known[ $orderby ] ) ) { $orderby = 'newest'; } // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' !== $error ) { printf( '

%s

', esc_html( $error ) ); } elseif ( 'recorded' === $notice ) { printf( '

%1$s %2$s

', esc_html__( 'Declaration recorded. Check the selected items and narrow them if the customer named only some.', 'careback' ), esc_html__( 'No message went out — reply to the customer yourself.', 'careback' ) ); // Shown next to the success notice, never instead of it: the record was // made, and this says what did not fit into it. The same wording is in // the history of the request, so it survives the page being reloaded. $left_out = isset( $_GET['careback_left_out'] ) ? absint( wp_unslash( $_GET['careback_left_out'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $row = $left_out ? $this->requests->get( $left_out ) : null; $note = is_array( $row ) ? $this->history_note( $row, 'items_left_out' ) : ''; if ( '' !== $note ) { printf( '

%1$s %2$s

%3$s

%4$s

', esc_html__( 'Not everything on the order went into this request.', 'careback' ), esc_html__( 'Earlier requests already hold every unit on these lines, so they were left out:', 'careback' ), esc_html( $note ), esc_html__( 'If the customer wrote about one of them, that part of the declaration is not recorded anywhere yet. Open the request named beside the item and decide there — nothing here is settled by leaving this notice.', 'careback' ) ); } } elseif ( 'arrival_corrected' === $notice ) { printf( '

%s

', esc_html__( 'Arrival date corrected.', 'careback' ) ); } elseif ( 'arrival_cleared' === $notice ) { printf( '

%1$s %2$s

', esc_html__( 'The arrival was taken back.', 'careback' ), esc_html__( 'Payment is on hold again: waiting for the goods or proof. It may be withheld until the goods are actually back or the customer supplies proof that they were sent; the refund deadline itself keeps running. The status is where you left it — move it yourself if the request should now sit somewhere else.', 'careback' ) ); } elseif ( 'proof' === $notice ) { printf( '

%s

', esc_html__( 'Proof of dispatch recorded.', 'careback' ) ); } elseif ( 'carrier_return' === $notice ) { printf( '

%1$s %2$s

', esc_html__( 'Recorded as a parcel the carrier brought back.', 'careback' ), esc_html__( 'Nothing was posted by the customer, so no return carriage may be withheld from this refund — the leg back was your own carrier contract, not their return shipment. What the failed delivery cost you, if you pursue it at all, is a separate claim and never a deduction here. The refund deadline is unchanged: it runs from the day the declaration reached you, and the parcel only ends your right to hold the money back.', 'careback' ) ); } elseif ( 'payout_confirmed' === $notice ) { printf( '

%1$s %2$s

', esc_html__( 'Payout confirmed over an overlapping request.', 'careback' ), esc_html__( 'The request you checked it against is named in the history of this one. Settle that request too, so the same item is not paid out a second time.', 'careback' ) ); } elseif ( 'closed_statutory' === $notice ) { printf( '

%1$s %2$s

', esc_html__( 'A request carrying a statutory withdrawal was closed.', 'careback' ), esc_html__( 'Your grounds are in its history. A statutory withdrawal itself stands whatever the status says: if the goods came back, the refund is still owed minus any loss in value.', 'careback' ) ); } elseif ( '' !== $notice ) { /** * Prints the notice named by `careback_notice` when it is not one of the * screen's own. See api/screen.md. * * @param string $notice Notice key from the query string. * @param Settings $settings Plugin settings. */ do_action( self::NOTICE, $notice, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_requests_notice, the final prefix. } // The whole query goes to the repository: an argument an add-on set // narrows the list through the repository's own hook. $query = $this->query_args( $status, $search, $type, $orderby ); $total = $this->requests->count( $query['status'], $query['search'], $query['type'], $query ); $pages = max( 1, (int) ceil( $total / self::PER_PAGE ) ); $rows = $this->requests->get_list( $query['status'], self::PER_PAGE, ( $paged - 1 ) * self::PER_PAGE, $query['search'], $query['orderby'], $query['type'], $query ); $columns = $this->list_columns(); $view = array( 'status' => $status, 'type' => $type, 'search' => $search, 'orderby' => $orderby, 'paged' => $paged, 'query' => $query, ); ?>
render_confirm_panel( $confirm_id, $confirm_status ); } ?> settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_requests_screen_top, the final prefix. ?> render_manual_entry(); ?> render_table_nav( $status, $search, $orderby, $type, $query ); ?> $column ) : ?> >render_column_header( $key, $column ); ?> $column ) : ?> >render_column_cell( $key, $column, $row ); ?>
settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_requests_screen_bottom, the final prefix. ?> 1 ) : ?>
add_query_arg( 'paged', '%#%' ), 'format' => '', 'current' => $paged, 'total' => $pages, 'prev_text' => '«', 'next_text' => '»', ) ) ); ?>
$status, 'search' => (string) $search, 'type' => $type, 'orderby' => (string) $orderby, ); /** * Filters the arguments the request list is queried with. See api/screen.md. * * @param array $query status, search, type, orderby. * @param Settings $settings Plugin settings. */ $query = apply_filters( self::QUERY_ARGS, $defaults, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_requests_query_args, the final prefix. return array_merge( $defaults, is_array( $query ) ? $query : array() ); } /** * Sort orders the list offers, key => label, in the order shown. A key is * used only when the repository knows it too. * * @return array */ private function orderings() { /** * Filters the sort orders offered above the list. See api/screen.md. * * @param array $orderings key => label. */ $orderings = apply_filters( self::ORDERINGS, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_list_orderings, the final prefix. array( 'newest' => __( 'Newest first', 'careback' ), 'oldest' => __( 'Oldest first', 'careback' ), ) ); return is_array( $orderings ) ? $orderings : array(); } /** * Filtering controls for the request list, printed as the top `tablenav` * row immediately above the table. Search, sort and the buttons sit on the * left; the status filter is pushed to the right edge of the table, above * the Status column, and stays there when the row wraps. * * One GET form. Submitting it reloads the screen with `s`, `status[]` and * `orderby` in the URL and without `paged`, so a filter starts on page one. * The status list is a `
` element, so it opens and closes without * JavaScript and every box is reachable with scripting off. * * @param array $status Statuses currently ticked. * @param string $search Current search term. * @param string $orderby Current sort key. * @param array $type Routes currently ticked. * @param array $query Arguments of the list query. */ private function render_table_nav( array $status, $search, $orderby, array $type = array(), array $query = array() ) { // An argument an add-on set beyond the screen's own four narrows the list // as much as a ticked status does, so it shows Clear too. $narrowed = (bool) array_filter( array_diff_key( $query, array_flip( array( 'status', 'search', 'type', 'orderby' ) ) ) ); ?>
settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_requests_toolbar, the final prefix. ?>
$label ) : ?>

definition array, or key => position. * @return array */ private static function in_order( array $items ) { $rank = array(); $index = 0; foreach ( $items as $key => $item ) { $position = is_array( $item ) ? ( isset( $item['position'] ) ? $item['position'] : 1000 ) : $item; $rank[ $key ] = array( is_numeric( $position ) ? (float) $position : 1000.0, $index ); ++$index; } uksort( $items, static function ( $a, $b ) use ( $rank ) { return $rank[ $a ] <=> $rank[ $b ]; } ); return $items; } /** * The columns of the list, in order. The screen's own carry no callbacks * and are printed here; an added one prints through its own. * * @return array */ private function list_columns() { $columns = array( 'toggle' => array( 'position' => 0, 'class' => 'careback-col-toggle', ), 'id' => array( 'position' => 10 ), 'order' => array( 'position' => 20 ), 'name' => array( 'position' => 30 ), 'items' => array( 'position' => 40 ), 'type' => array( 'position' => 50 ), 'created' => array( 'position' => 60 ), 'status' => array( 'position' => 100 ), ); /** * Filters the columns of the request list. See api/screen.md. * * @param array $columns key => position, class, header, cell. * @param Settings $settings Plugin settings. */ $columns = apply_filters( self::COLUMNS, $columns, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_request_list_columns, the final prefix. $out = array(); foreach ( is_array( $columns ) ? $columns : array() as $key => $column ) { $column = is_array( $column ) ? $column : array(); $out[ $key ] = array( 'position' => isset( $column['position'] ) ? $column['position'] : 1000, 'class' => isset( $column['class'] ) ? (string) $column['class'] : '', 'header' => isset( $column['header'] ) && is_callable( $column['header'] ) ? $column['header'] : null, 'cell' => isset( $column['cell'] ) && is_callable( $column['cell'] ) ? $column['cell'] : null, ); } return self::in_order( $out ); } /** * Content of one column heading. * * @param string $key Column key. * @param array $column Its definition. */ private function render_column_header( $key, array $column ) { if ( null !== $column['header'] ) { call_user_func( $column['header'], $this->settings ); return; } switch ( $key ) { case 'id': esc_html_e( 'ID', 'careback' ); break; case 'order': echo esc_html_x( 'Order', 'admin list column', 'careback' ); break; case 'name': echo esc_html_x( 'Name', 'admin list column', 'careback' ); break; case 'items': echo esc_html_x( 'Items', 'admin list column', 'careback' ); break; case 'type': esc_html_e( 'Type', 'careback' ); break; case 'created': echo esc_html_x( 'Created', 'admin list column', 'careback' ); break; case 'status': echo esc_html_x( 'Status', 'admin list column', 'careback' ); $this->status_guide_tip(); break; } } /** * Content of one cell of a request row. * * @param string $key Column key. * @param array $column Its definition. * @param array $row Request row. */ private function render_column_cell( $key, array $column, array $row ) { if ( null !== $column['cell'] ) { call_user_func( $column['cell'], $row, $this->settings ); return; } switch ( $key ) { case 'toggle': ?> render_type( $row ); break; case 'created': echo esc_html( $row['created_at'] ); break; case 'status': $this->render_status_cell( $row ); break; } } /** * The status cell: the form that changes it, or — on a row the screen may * not change — the stored value as it is. * * @param array $row Request row. */ private function render_status_cell( array $row ) { if ( ! self::editable( $row ) ) { echo esc_html( Statuses::display_label( (string) $row['status'] ) ); return; } ?>
render_goods_origin_select( $row ); ?> settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_request_status_fields, the final prefix. ?>
*/ private $order_cache = array(); /** * The order behind a request, loaded at most once per page render. * * @return \WC_Order|false */ private function order( $order_id ) { $id = (int) $order_id; if ( ! $id || ! Plugin::woocommerce_active() ) { return false; } if ( ! array_key_exists( $id, $this->order_cache ) ) { $order = wc_get_order( $id ); $this->order_cache[ $id ] = $order instanceof \WC_Order ? $order : false; } return $this->order_cache[ $id ]; } /** * Contact details for a request. Pulled live from the WooCommerce order so * no plaintext email or phone is ever stored by the plugin (the request row * only keeps a one-way email hash). The order is the single source of truth. * * @return array{first_name:string,last_name:string,email:string,phone:string,shipping:string,found:bool} */ private function order_contact( $order_id ) { $out = array( 'first_name' => '', 'last_name' => '', 'email' => '', 'phone' => '', 'shipping' => '', 'found' => false ); $order = $this->order( $order_id ); if ( ! $order instanceof \WC_Order ) { return $out; } $out['found'] = true; $out['first_name'] = trim( (string) $order->get_billing_first_name() ); $out['last_name'] = trim( (string) $order->get_billing_last_name() ); $out['email'] = (string) $order->get_billing_email(); $out['phone'] = (string) $order->get_billing_phone(); // The postal address only — the customer name is shown on its own row, so // the address is composed from its parts (WooCommerce's formatted address // would repeat the name on the first line). $use_shipping = '' !== trim( (string) $order->get_shipping_address_1() ); $prefix = $use_shipping ? 'shipping' : 'billing'; $getter = static function ( $field ) use ( $order, $prefix ) { $method = 'get_' . $prefix . '_' . $field; return method_exists( $order, $method ) ? (string) $order->$method() : ''; }; $lines = array_filter( array( trim( $getter( 'address_1' ) ), trim( $getter( 'address_2' ) ), trim( $getter( 'postcode' ) . ' ' . $getter( 'city' ) ), trim( $getter( 'country' ) ), ), static function ( $line ) { return '' !== $line; } ); $out['shipping'] = implode( "\n", $lines ); return $out; } /** The help icon every screen draws (Admin_Tip::render()). */ private function tip( $text ) { Admin_Tip::render( $text ); } /** * Type cell: which route the request is on, in words, and the badges that * say what is special about it. * * The raw column holds a slug, and it alone does not tell the operator what * to do, so the badges are printed next to it, each in its place: the * statutory mark first, the flags written at filing after it, the * timeliness of the declaration last. */ private function render_type( array $row ) { $type = isset( $row['type'] ) ? (string) $row['type'] : ''; // One name per stored type value, in the reader's language (step 6); the // `type` column keeps the key. $names = self::type_labels(); printf( '%s', esc_html( isset( $names[ $type ] ) ? $names[ $type ] : $type ) ); foreach ( $this->list_badges( $row ) as $badge ) { call_user_func( $badge['render'], $row, $this->settings ); } } /** * The badges of one row's Type cell, in order. * * @param array $row Request row. * @return array */ private function list_badges( array $row ) { $badges = array( // A statutory declaration is the only route with a refund deadline, and // the badge is what tells it apart from the voluntary offer above it. 'statutory' => array( 'position' => 10, 'render' => function ( $row ) { if ( Deadlines::is_withdrawal( $row ) ) { printf( '%2$s', esc_attr__( 'A statutory declaration: a refund deadline runs on this request.', 'careback' ), esc_html__( 'deadline', 'careback' ) ); } }, ), // The badge names the stored `fault_claimed` flag, in the reader's language (the flag keeps its key). 'fault' => array( 'position' => 40, 'render' => function ( $row ) { if ( $this->has_fault_flag( $row ) ) { printf( '%2$s', esc_attr__( 'The customer picked a reason you marked as a fault on your side.', 'careback' ), esc_html_x( 'fault claimed', 'request flag', 'careback' ) ); } }, ), // The badge names the stored `supersedes_claim` flag, in the reader's language (the flag keeps its key). 'supersedes' => array( 'position' => 50, 'render' => function ( $row ) { if ( $this->supersedes_claim( $row ) ) { printf( '%2$s', esc_attr__( 'Filed over units an earlier request holds. Settle it against that request before paying anything out.', 'careback' ), esc_html_x( 'supersedes claim', 'request flag', 'careback' ) ); } }, ), // The badge names the stored `superseded_claim` flag, in the reader's language (the flag keeps its key). 'superseded' => array( 'position' => 60, 'render' => function ( $row ) { if ( $this->superseded_claim( $row ) ) { printf( '%2$s', esc_attr__( 'A later statutory withdrawal covers the units of this request. Settle the two together.', 'careback' ), esc_html_x( 'superseded', 'request flag', 'careback' ) ); } }, ), 'filed' => array( 'position' => 100, 'render' => function ( $row ) { $this->render_filed_badge( $row ); }, ), ); /** * Filters the badges printed after the route name in a row's Type cell. * See api/screen.md. * * @param array $badges key => position, render( array $row, Settings $settings ). * @param array $row Request row. * @param Settings $settings Plugin settings. */ $badges = apply_filters( self::BADGES, $badges, $row, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_request_list_badges, the final prefix. return self::callable_parts( $badges, 'render' ); } /** * Definitions of a positioned list that carry a callable under $key, in * their order. * * @param mixed $parts Filtered list. * @param string $key Property that must be callable. * @return array */ private static function callable_parts( $parts, $key ) { $out = array(); foreach ( is_array( $parts ) ? $parts : array() as $name => $part ) { if ( is_array( $part ) && isset( $part[ $key ] ) && is_callable( $part[ $key ] ) ) { $out[ $name ] = $part; } } return self::in_order( $out ); } private function status_guide_tip() { $guide = array( 'new' => __( 'Just arrived, not handled yet (this is what the menu badge counts).', 'careback' ), 'confirmed' => __( 'You accepted the request and are handling it.', 'careback' ), 'label_sent' => __( 'Return instructions or a shipping label were sent to the customer.', 'careback' ), 'received' => __( 'The returned parcel has arrived at you.', 'careback' ), 'inspected' => __( 'You checked the returned goods (condition, completeness).', 'careback' ), 'refunded' => __( 'Money was refunded to the customer. Closed. (Do the actual refund in WooCommerce.)', 'careback' ), 'rejected' => __( 'The return was not accepted. Closed.', 'careback' ), 'duplicate' => __( 'A duplicate of an existing request (customer submitted twice).', 'careback' ), 'cancelled' => __( 'The request was cancelled. Closed.', 'careback' ), ); $lines = ''; foreach ( $guide as $key => $desc ) { $lines .= '' . esc_html( $key ) . ' — ' . esc_html( $desc ) . '
'; } ?> array(), 'br' => array() ) ); ?> order( isset( $row['order_id'] ) ? (int) $row['order_id'] : 0 ); if ( ! $order instanceof \WC_Order ) { return null; } // How long the customer had to declare, not how long the shop has to pay: // a shop that widens the window must not see a declaration made inside it // flagged as late (04 §11.3). $days = $this->settings instanceof Settings ? (int) $this->settings->get( 'statutory_window_days', 14 ) : 14; return Deadlines::filed_in_time( $row, $order, $days ); } /** * Badge naming the timeliness of a declaration in the list. Renders nothing * for a request carrying no statutory declaration, and nothing when the * declaration was in time; a late and an undecidable one each get their own * badge. */ private function render_filed_badge( array $row ) { if ( ! Deadlines::is_withdrawal( $row ) ) { return; } $in_time = $this->filed_in_time( $row ); if ( false === $in_time ) { printf( '%1$s', esc_attr__( 'Filed after the statutory window', 'careback' ) ); return; } if ( null === $in_time ) { printf( '%1$s', esc_attr__( 'Timeliness unknown', 'careback' ) ); } } /** * Timeliness of the declaration on the detail panel, with the date it was * sent when one is recorded. All three answers are rendered. Advisory: no * action is blocked by it. */ private function render_filed_detail( array $row ) { if ( ! Deadlines::is_withdrawal( $row ) ) { return; } $in_time = $this->filed_in_time( $row ); $sent = Deadlines::sent_at( $row ); if ( false === $in_time ) { $label = __( 'Filed after the statutory window', 'careback' ); $class = 'careback-filed-note is-late'; } elseif ( null === $in_time ) { $label = __( 'Timeliness unknown', 'careback' ); $class = 'careback-filed-note is-unknown'; } else { $label = __( 'Filed in time', 'careback' ); $class = 'careback-filed-note'; } printf( '

%2$s%3$s

', esc_attr( $class ), esc_html( $label ), /* translators: a PHP date() format for a short date (29.10.2026): keep the letters, set the order and separators of your language. */ $sent ? ' ' . esc_html( wp_date( _x( 'd.m.Y', 'short date format (PHP date())', 'careback' ), $sent ) ) : '' ); } /** * The blocks of the request card, in order, each with its region: `top` * above the grid, `grid` in it, `after` below the history. * * @param array $row Request row. * @return array */ private function detail_blocks( array $row ) { $blocks = array( 'contact' => array( 'region' => 'grid', 'position' => 10, 'render' => function ( $row ) { $this->render_contact( $row ); }, ), 'items' => array( 'region' => 'grid', 'position' => 20, 'render' => function ( $row ) { $this->render_items( $row ); }, ), 'comment' => array( 'region' => 'grid', 'position' => 30, 'render' => function ( $row ) { $this->render_comment( $row ); }, ), 'arrival' => array( 'region' => 'grid', 'position' => 40, 'render' => function ( $row ) { $this->render_arrival( $row ); }, ), 'resolution' => array( 'region' => 'after', 'position' => 10, 'render' => function ( $row ) { $this->render_resolution( $row ); }, ), 'dispatch_proof' => array( 'region' => 'after', 'position' => 30, 'render' => function ( $row ) { $this->render_dispatch_proof( $row ); }, ), ); /** * Filters the blocks of the request card. See api/screen.md. * * @param array $blocks key => region (top, grid, after), position, * render( array $row, Settings $settings ). * @param array $row Request row. * @param Settings $settings Plugin settings. */ $blocks = apply_filters( self::DETAIL_BLOCKS, $blocks, $row, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_request_detail_blocks, the final prefix. return self::callable_parts( $blocks, 'render' ); } /** * Prints the blocks of one region of the request card. * * @param array $blocks Blocks in order. * @param string $region Region to print. * @param array $row Request row. */ private function render_blocks( array $blocks, $region, array $row ) { foreach ( $blocks as $block ) { $in = isset( $block['region'] ) ? (string) $block['region'] : 'grid'; if ( $region === $in ) { call_user_func( $block['render'], $row, $this->settings ); } } } /** Inline detail panel: contact, items, reason, comment and status history. */ private function render_detail( array $row ) { $blocks = $this->detail_blocks( $row ); ?>
render_blocks( $blocks, 'top', $row ); ?>
render_blocks( $blocks, 'grid', $row ); ?>
render_history( $row ); ?> render_blocks( $blocks, 'after', $row ); ?>
order_contact( $row['order_id'] ); $order_url = admin_url( 'post.php?post=' . (int) $row['order_id'] . '&action=edit' ); ?>

' . esc_html__( 'not on file', 'careback' ) . '', array( 'em' => array() ) ); ?>

' . esc_html__( 'not on file', 'careback' ) . '', array( 'em' => array() ) ); ?>


array() ) ); } else { echo '' . esc_html__( 'not on file', 'careback' ) . ''; } ?>

render_submitted( $row ); ?>

→

render_submitted( $row ); ?>

settings ); $reason = (string) $row['reason']; $reason_label = ( '' !== $reason && isset( $reasons[ $reason ] ) ) ? $reasons[ $reason ] : $reason; ?>

    order( $row['order_id'] ), isset( $item['line_item_id'] ) ? (int) $item['line_item_id'] : 0, $careback_name ); $careback_title = empty( $careback_parts['details'] ) ? $careback_name : $careback_parts['title']; ?>
  • ' . esc_html( $careback_detail ) . ''; } ?>
render_policy_consent( $row ); ?>

' . esc_html__( 'not given', 'careback' ) . '', array( 'em' => array() ) ); ?>

has_fault_flag( $row ) ) : ?>

supersedes_claim( $row ) ) : ?>

history_note( $row, 'supersedes' ) ) ); ?>

superseded_claim( $row ) ) : ?>

history_note( $row, 'superseded' ) ) ); ?>

render_filed_detail( $row ); ?>

'Proof of dispatch recorded', 'goods_arrival_corrected' => 'Arrival date corrected', 'goods_arrival_cleared' => 'Arrival taken back: nothing usable in the parcel', 'items_left_out' => 'Order lines left out: already held by another request', 'supersedes' => 'Filed over an earlier request', 'superseded' => 'Covered by a later withdrawal', 'payout_override' => 'Payout confirmed despite an overlapping request', 'closure_grounds' => 'Grounds for closing a statutory withdrawal', 'policy_accepted' => 'Returns policy accepted', ); ?>

    'Goods arrived: the customer sent them back', 'carrier_return' => 'Goods arrived: returned by the carrier, not collected', 'not_dispatched' => 'Goods arrived: never dispatched', ); $lbl = isset( $origins[ $note ] ) ? $origins[ $note ] : 'Goods arrival recorded'; $note = ''; } elseif ( '' === $lbl ) { $lbl = $kind; } /** * Filters the name of one history entry. See api/screen.md. * * @param string $lbl Name the screen gave it. * @param array $event The entry: at, by, and status or kind, note. * @param array $row Request row. */ $lbl = (string) apply_filters( self::HISTORY_LABEL, $lbl, $event, $row ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_history_entry_label, the final prefix. ?>
  • —
* fixed English literal. A value nothing names is shown as stored. See * api/screen.md. * * @param array $labels value => label. */ $labels = apply_filters( self::RESOLUTION_LABELS, array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_resolution_labels, the final prefix. $labels = is_array( $labels ) ? $labels : array(); $label = isset( $labels[ $resolution ] ) ? $labels[ $resolution ] : $resolution; $note = isset( $row['resolution_note'] ) ? trim( (string) $row['resolution_note'] ) : ''; $at = isset( $row['resolution_at'] ) ? trim( (string) $row['resolution_at'] ) : ''; ?>

_x( 'The customer sent it back', 'goods origin', 'careback' ), 'carrier_return' => _x( 'Returned by the carrier, not collected', 'goods origin', 'careback' ), 'not_dispatched' => _x( 'Never dispatched', 'goods origin', 'careback' ), ); $unknown = '' . esc_html__( 'not recorded', 'careback' ) . ''; ?>

array() ) ); ?>

array() ) ); ?>



requests->get( $id ) ) { $this->redirect_with_error( __( 'Request not found.', 'careback' ) ); } if ( 'clear' === $what ) { $note = isset( $_POST['arrival_note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['arrival_note'] ) ) : ''; if ( ! self::grounds_given( $note ) ) { $this->redirect_with_error( __( 'Write down what was in the parcel. The arrival is not taken back without it.', 'careback' ) ); } if ( ! $this->requests->clear_goods_arrival( $id, $note ) ) { $this->redirect_with_error( __( 'There is no recorded arrival to take back on this request.', 'careback' ) ); } wp_safe_redirect( add_query_arg( array( 'page' => 'careback', 'careback_notice' => 'arrival_cleared' ), admin_url( 'admin.php' ) ) ); exit; } $date = isset( $_POST['arrived_on'] ) ? $this->normalise_date( sanitize_text_field( wp_unslash( $_POST['arrived_on'] ) ), $id ) : ''; if ( '' === $date ) { $this->redirect_with_error( __( 'Enter the day the parcel reached you. It cannot be in the future or long before the request was filed.', 'careback' ) ); } if ( ! $this->requests->correct_goods_arrival( $id, $date ) ) { $this->redirect_with_error( __( 'The date could not be saved. It may already be the date on record.', 'careback' ) ); } wp_safe_redirect( add_query_arg( array( 'page' => 'careback', 'careback_notice' => 'arrival_corrected' ), admin_url( 'admin.php' ) ) ); exit; } /** * What a declaration recorded by hand can be, route => label, in the order * offered. The first one is the default. * * @return array */ private function manual_entry_types() { /** * Filters the routes a declaration recorded by hand can be filed on. See * api/screen.md. * * @param array $types route => label. * @param Settings $settings Plugin settings. */ $types = apply_filters( self::MANUAL_TYPES, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_manual_entry_types, the final prefix. array( 'withdrawal' => __( 'A withdrawal from the purchase', 'careback' ), 'return' => __( 'A return under the voluntary offer', 'careback' ), ), $this->settings ); $types = is_array( $types ) ? $types : array(); // A route no registration names is not offered: it would be stored as a // type nothing reads (or, from a stale callback, one the registry no // longer knows). foreach ( array_keys( $types ) as $route ) { if ( ! Routes::exists( $route ) ) { unset( $types[ $route ] ); } } // The statutory declaration is always there, and always first: it is the // default a letter is recorded as. $label = isset( $types['withdrawal'] ) ? $types['withdrawal'] : __( 'A withdrawal from the purchase', 'careback' ); unset( $types['withdrawal'] ); return array( 'withdrawal' => $label ) + $types; } /** * Records a declaration that reached the shop outside the portal. * * A declaration is not bound to any form, so one sent by email or on paper * is valid from the day it arrived. Without this the request never enters the * pipeline and its deadline is tracked nowhere. */ private function render_manual_entry() { $today = wp_date( 'Y-m-d' ); $types = $this->manual_entry_types(); $last = array_key_last( $types ); ?>

tip( __( 'Answered when you save the status "Parcel received". A parcel your carrier sent back was never posted by the customer, so no return carriage may be withheld from their refund. Leave the date empty only if it arrived today.', 'careback' ) ); ?> Gate keys, in order. */ public static function confirmation_gates( array $row, $status, ...$args ) { $status = (string) $status; // Saving the form on the value a request already holds changes nothing, // and nothing is what there is to answer for. if ( isset( $row['status'] ) && (string) $row['status'] === $status ) { return array(); } $gates = array(); if ( in_array( $status, Statuses::money(), true ) && Requests_Repository::overlaps_claim( $row ) ) { $gates['payout'] = 30; } if ( in_array( $status, Statuses::asking_grounds(), true ) && Requests_Repository::is_statutory( $row ) ) { $gates['grounds'] = 50; } /** * Filters the confirmations a status change has to carry. See * api/screen.md. * * @param array $gates gate => position (lower is asked first). * @param array $row Request row. * @param string $status Status being set. * @param array $args Further arguments of the caller. */ $gates = apply_filters( self::GATES, $gates, $row, $status, $args ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_confirmation_gates, the final prefix. return array_map( 'strval', array_keys( self::in_order( is_array( $gates ) ? $gates : array() ) ) ); } /** * The first confirmation a status change on this request has to carry. * * @param array $row Request row. * @param string $status Status being set. * @param mixed ...$args Further arguments, as confirmation_gates() takes them. * @return string One of the values of confirmation_gates(), or '' when the * change needs no confirmation. */ public static function confirmation_required( array $row, $status, ...$args ) { $gates = self::confirmation_gates( $row, $status, ...$args ); return empty( $gates ) ? '' : $gates[0]; } /** * True when the operator ticked the acknowledgement at a payout gate. * * The gate used to take the number of the overlapping request typed by hand. * That number is printed on the panel directly above the field, so typing it * copied what was already on screen and proved nothing; the panel now states * where each overlapping request stands and takes a deliberate tick instead. * Only the value the checkbox posts is accepted, so a stray parameter cannot * pass the gate. */ public static function payout_acknowledged( $value ) { return '1' === (string) $value; } /** * One name per stored type value, in the reader's language (step 6, * translations; the `type` column keeps the key): the registered routes give * theirs through Routes::display_admin_label(); the types no route files any * more are named here. * * Shared by the cell and the filter so the two cannot drift apart. */ public static function type_labels() { $labels = array(); foreach ( array_keys( Routes::all() ) as $key ) { $labels[ $key ] = Routes::display_admin_label( $key ); } $labels['defect'] = _x( 'Fault (no longer filed)', 'request type', 'careback' ); return $labels; } /** * The grounds the closing gate offers, as value => label, in the order they * are put to the operator. The one that keeps the clock running is last and * is the one selected by default, so a panel clicked through without being * read leaves the deadline in place rather than clearing it. */ public static function closure_ground_labels() { return array( 'duplicate' => __( 'The same declaration is already filed as another request', 'careback' ), 'declaration_withdrawn' => __( 'The customer took the declaration back', 'careback' ), 'not_valid' => __( 'There was no right of withdrawal for this contract', 'careback' ), Deadlines::GROUND_MONEY_OWED => __( 'We are refusing it, but the money is still owed', 'careback' ), ); } /** True when the posted ground is one the gate offers. */ public static function closure_ground_valid( $value ) { return in_array( (string) $value, Deadlines::CLOSURE_GROUNDS, true ); } /** True when the grounds field carries text. */ public static function grounds_given( $value ) { return '' !== trim( (string) $value ); } /** * True when the answer posted at a gate is accepted. * * A payout gate takes the acknowledgement ticked beside the overlapping * requests it names. A row whose history no longer names one — an anonymised * row keeps its flags and loses its history — has nothing to name and nothing * to tick, so it takes wording instead and the gate cannot lock a request out * of every money status. A closing gate takes a ground and the wording. * Every other gate is answered through the accepted filter. * * The answers come as one array of posted field => value. The form of * earlier versions is still taken: the values after $gate one by one, the * tick first, the wording second and the ground fourth; all of them are * handed on under `positional`. * * @param array $row Request row. * @param string $gate One value of confirmation_gates(). * @param array|string $answers Answers by field name, or the first positional value. * @param string ...$more The further positional values. * @return bool */ public static function confirmation_accepted( array $row, $gate, $answers = array(), ...$more ) { if ( ! is_array( $answers ) ) { $answers = self::positional_answers( array_merge( array( $answers ), $more ) ); } $gate = (string) $gate; if ( 'grounds' === $gate ) { $accepted = self::closure_ground_valid( self::answer( $answers, 'closure_ground' ) ) && self::grounds_given( self::answer( $answers, 'grounds' ) ); } elseif ( 'payout' === $gate ) { $accepted = Requests_Repository::overlap_ids( $row ) ? self::payout_acknowledged( self::answer( $answers, 'confirm_overlap_checked' ) ) : self::grounds_given( self::answer( $answers, 'grounds' ) ); } else { $accepted = true; } /** * Filters whether the answers given at a gate are accepted. See * api/screen.md. * * @param bool $accepted The screen's answer (true for a gate it does not know). * @param string $gate Gate key. * @param array $row Request row. * @param array $answers Posted field => value; `positional` in the old form. */ return (bool) apply_filters( self::GATE_ACCEPTED, $accepted, $gate, $row, $answers ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_confirmation_accepted, the final prefix. } /** * Answers given the way earlier versions passed them, by name. * * @param array $values The values after the gate, in order. * @return array */ private static function positional_answers( array $values ) { $values = array_values( $values ); return array( 'confirm_overlap_checked' => isset( $values[0] ) ? (string) $values[0] : '', 'grounds' => isset( $values[1] ) ? (string) $values[1] : '', 'closure_ground' => isset( $values[3] ) ? (string) $values[3] : '', 'positional' => $values, ); } /** * One answer, as text. * * @param array $answers Answers by field name. * @param string $name Field name. * @return string */ private static function answer( array $answers, $name ) { return isset( $answers[ $name ] ) && is_scalar( $answers[ $name ] ) ? (string) $answers[ $name ] : ''; } /** The message shown when an answer given at a gate was not accepted. */ private static function gate_error( array $row, $gate ) { if ( 'grounds' === $gate ) { $message = __( 'Pick why this request is being closed and write down what happened. The status is not saved without both.', 'careback' ); } elseif ( Requests_Repository::overlap_ids( $row ) ) { $message = sprintf( /* translators: %s: numbers of the overlapping requests, e.g. "#25". */ __( 'Open %s and check what was paid there, then tick the box. The status is not saved until you do.', 'careback' ), implode( ', ', array_map( static function ( $overlap_id ) { return '#' . (int) $overlap_id; }, Requests_Repository::overlap_ids( $row ) ) ) ); } else { $message = __( 'Write down what you checked this payout against. The status is not saved without it.', 'careback' ); } /** * Filters the message shown when the answer given at a gate was not * accepted. See api/screen.md. * * @param string $message The screen's message. * @param string $gate Gate key. * @param array $row Request row. */ return (string) apply_filters( self::GATE_ERROR, $message, (string) $gate, $row ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_confirmation_gate_error, the final prefix. } /** * The parts of the confirmation step for the gates it asks, in order. * * @param string $hook Filter that completes the list. * @param array $own The screen's own parts, gate => definition. * @param array $gates Gates asked. * @param array $row Request row. * @param string $status Status being set. * @return array */ private static function panel_parts( $hook, array $own, array $gates, array $row, $status ) { /** * Filters the explanations (careback_confirm_panel_sections) or the * fields (careback_confirm_panel_fields) of the confirmation step. A part * is printed only while its gate is asked. See api/screen.md. * * @param array $parts gate => position, render( array $row, string $status, array $gates ), class. * @param array $gates Gates asked, in order. * @param array $row Request row. * @param string $status Status being set. */ $parts = apply_filters( $hook, $own, $gates, $row, (string) $status ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_confirm_panel_sections or careback_confirm_panel_fields, the final prefix. $parts = self::callable_parts( $parts, 'render' ); return array_intersect_key( $parts, array_flip( $gates ) ); } /** * The step a guarded status change passes through before it is written: it * names what is at stake and takes the operator's answer. * * Rendered from the query string as a plain form posting to the same status * action, so the step also stands with JavaScript switched off. */ private function render_confirm_panel( $id, $status ) { $row = $this->requests->get( $id ); if ( ! $row ) { return; } $gates = self::confirmation_gates( $row, $status, $this->settings ); if ( empty( $gates ) ) { return; } $sections = self::panel_parts( self::GATE_SECTIONS, array( 'payout' => array( 'position' => 40, 'render' => static function ( $row, $status ) { self::render_payout_section( $row, $status ); }, ), 'grounds' => array( 'position' => 70, 'render' => static function ( $row, $status ) { self::render_grounds_section( $row, $status ); }, ), ), $gates, $row, $status ); $fields = self::panel_parts( self::GATE_FIELDS, array( 'payout' => array( 'position' => 10, 'render' => static function ( $row, $status ) { self::render_payout_fields( $row, $status ); }, ), 'grounds' => array( 'position' => 20, 'render' => static function ( $row, $status, $gates ) { self::render_grounds_fields( $row, $status, $gates ); }, ), ), $gates, $row, $status ); $classes = array( 'careback-confirm' ); foreach ( $sections as $section ) { if ( ! empty( $section['class'] ) ) { $classes[] = (string) $section['class']; } } ?>


$label ) : ?>


requests->get( $id ) : null; if ( $row && ! Statuses::allowed_for_type( $status, (string) $row['type'], isset( $row['resolution'] ) ? (string) $row['resolution'] : '', (string) $row['status'] ) ) { $status = ''; } // A row the screen shows as stored is not changed from here either. if ( $row && ! self::editable( $row ) ) { $status = ''; } if ( ! $id || ! in_array( $status, Statuses::keys(), true ) ) { wp_safe_redirect( admin_url( 'admin.php?page=careback' ) ); exit; } // Some changes are held back until the operator has answered for them — // here a payout on units another request also holds and the closing of a // statutory declaration, and whatever the gates filter adds. None is // refused; each is stopped once, and a change carrying two of them takes // both answers. $gates = $row ? self::confirmation_gates( $row, $status, $this->settings ) : array(); $answered = ! empty( $_POST['careback_confirm_submit'] ); $answers = array( 'confirm_overlap_checked' => isset( $_POST['confirm_overlap_checked'] ) ? sanitize_text_field( wp_unslash( $_POST['confirm_overlap_checked'] ) ) : '', 'grounds' => isset( $_POST['grounds'] ) ? sanitize_textarea_field( wp_unslash( $_POST['grounds'] ) ) : '', 'closure_ground' => isset( $_POST['closure_ground'] ) ? sanitize_key( wp_unslash( $_POST['closure_ground'] ) ) : '', 'closure_ground_ref' => isset( $_POST['closure_ground_ref'] ) ? absint( preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_POST['closure_ground_ref'] ) ) ) ) : 0, ); if ( $row ) { /** * Filters the answers posted with a status change: add the fields of a * gate of one's own. The nonce has been checked. See api/screen.md. * * @param array $answers Posted field => sanitised value. * @param array $gates Gates asked. * @param array $row Request row. */ $answers = apply_filters( self::GATE_ANSWERS, $answers, $gates, $row ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_confirmation_answers, the final prefix. $answers = is_array( $answers ) ? $answers : array(); } foreach ( $gates as $gate ) { if ( ! self::confirmation_accepted( $row, $gate, $answers ) ) { $this->redirect_to_confirm( $id, $status, $answered ? self::gate_error( $row, $gate ) : '' ); } } if ( in_array( 'payout', $gates, true ) ) { $this->requests->record_payout_override( $id, Requests_Repository::overlap_ids( $row ), self::answer( $answers, 'grounds' ) ); } if ( $row ) { /** * Fires once every gate is answered, before the closing grounds and the * status are written: record what was answered at a gate of one's own. * See api/screen.md. * * @param array $gates Gates asked. * @param int $id Request id. * @param array $row Request row as it was read. * @param array $answers Posted answers. * @param string $status Status being set. */ do_action( self::GATE_ANSWERED, $gates, $id, $row, $answers, $status ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_gate_answered, the final prefix. } if ( in_array( 'grounds', $gates, true ) ) { $ground = self::answer( $answers, 'closure_ground' ); $ground_ref = isset( $answers['closure_ground_ref'] ) ? absint( $answers['closure_ground_ref'] ) : 0; // A duplicate has to name a real request on the same order. Without the // check the clock could be cleared by typing any number at all, which is // the one ground that silences a live refund deadline on a guess. if ( 'duplicate' === $ground && ! $this->duplicate_of( $row, $ground_ref ) ) { $this->redirect_to_confirm( $id, $status, __( 'Enter the number of another request on this same order. Nothing is cleared by a number that does not name one.', 'careback' ) ); } // Written before the status change, so the clock is recalculated with // the ground already on the row. $this->requests->record_closure_grounds( $id, self::answer( $answers, 'grounds' ), $ground, $ground_ref ); } if ( $row ) { /** * Fires just before the status is written, once every answer is on the * row. A callback may still send the operator back. See api/screen.md. * * @param int $id Request id. * @param string $status Status being set. * @param array $row Request row as it was read. */ do_action( self::STATUS_SAVING, $id, $status, $row ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_status_saving, the final prefix. } // Where the parcel came from decides what may be withheld from the refund, // and it is recorded before the status is written: the date entered with it // is then already on the row, and the status change leaves it alone. $carrier_return = false; if ( 'received' === $status && $row && '' === Requests_Repository::goods_origin( $row ) ) { $origin = isset( $_POST['goods_origin'] ) ? sanitize_key( wp_unslash( $_POST['goods_origin'] ) ) : Requests_Repository::DEFAULT_GOODS_ORIGIN; $arrived = isset( $_POST['goods_received_on'] ) ? $this->normalise_date( sanitize_text_field( wp_unslash( $_POST['goods_received_on'] ) ), $id ) : ''; $this->requests->record_goods_arrival( $id, $origin, $arrived ); $carrier_return = Requests_Repository::CARRIER_RETURN_ORIGIN === $origin; } $this->requests->update_status( $id, $status, 'admin' ); $args = array( 'page' => 'careback' ); if ( $carrier_return ) { $args['careback_notice'] = 'carrier_return'; } // What was confirmed at the gate is restated after the write, because the // answer given there is the only record that the overlap or the statutory // declaration was considered. if ( in_array( 'payout', $gates, true ) ) { $args['careback_notice'] = 'payout_confirmed'; } elseif ( in_array( 'grounds', $gates, true ) ) { $args['careback_notice'] = 'closed_statutory'; } if ( $row ) { /** * Filters where the operator is sent once the status is written: the * query arguments of the list, `careback_notice` among them. See * api/screen.md. * * @param array $args Query arguments. * @param int $id Request id. * @param string $status Status written. * @param array $row Request row as it was before the change. * @param array $gates Gates that were answered. * @param Settings $settings Plugin settings. */ $args = apply_filters( self::STATUS_SAVED_ARGS, $args, $id, $status, $row, $gates, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_status_saved_args, the final prefix. $args = is_array( $args ) ? $args : array( 'page' => 'careback' ); } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } /** * Creates a request from a declaration that arrived outside the portal. * * Every still-available unit of the order is claimed: a letter rarely lists * item numbers, and an operator narrows the request afterwards. The dates * come from the message, never from today. */ public function manual_entry() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'careback' ) ); } check_admin_referer( 'careback_manual_entry' ); $number = isset( $_POST['order_number'] ) ? sanitize_text_field( wp_unslash( $_POST['order_number'] ) ) : ''; $order = Order_Lookup::find( $number ); if ( ! $order instanceof \WC_Order ) { $this->redirect_with_error( __( 'No order found with that number.', 'careback' ) ); } // A declaration cannot predate the contract, which gives a free lower // bound against a mistyped year. $placed = $order->get_date_created(); $floor = $placed ? $placed->getTimestamp() : 0; $received = $this->normalise_date( isset( $_POST['received_at'] ) ? sanitize_text_field( wp_unslash( $_POST['received_at'] ) ) : '', 0, $floor ); if ( '' === $received ) { $this->redirect_with_error( __( 'Enter the date the declaration reached you. It cannot be in the future or before the order was placed.', 'careback' ) ); } $sent = $this->normalise_date( isset( $_POST['sent_at'] ) ? sanitize_text_field( wp_unslash( $_POST['sent_at'] ) ) : '', 0, $floor ); // A declaration cannot be sent after it arrived. A mistyped dispatch date // later than the arrival would make an in-time declaration read as late // and strip the statutory clock below, so it is dropped rather than used. if ( $sent && strtotime( $sent . ' UTC' ) > strtotime( $received . ' UTC' ) ) { $sent = ''; } $posted_flow = isset( $_POST['flow'] ) ? sanitize_key( wp_unslash( $_POST['flow'] ) ) : ''; $flow = array_key_exists( $posted_flow, $this->manual_entry_types() ) ? $posted_flow : 'withdrawal'; // A route that takes no unit with it — a fault report: the customer keeps // the item — is recorded against every line at the quantity ordered. $no_units = in_array( $flow, Routes::non_consuming_types(), true ); $flags = isset( $_POST['fault_claimed'] ) ? Reasons::FAULT_FLAG : ''; $items = array(); $all = array(); $closed = array(); $claims = $this->requests->claims_by_line( $order->get_id() ); $lines = $order->get_items(); foreach ( Order_Lookup::items( $order, $this->requests ) as $item ) { // The product and the variation come from the order's own line, as the // portal stores them: the per-product and per-variation return figures // are counted from these two, and skip a line that has neither. $line = isset( $lines[ (int) $item['line_item_id'] ] ) ? $lines[ (int) $item['line_item_id'] ] : null; $entry = array( 'line_item_id' => (int) $item['line_item_id'], 'product_id' => $line instanceof \WC_Order_Item_Product ? (int) $line->get_product_id() : 0, 'variation_id' => $line instanceof \WC_Order_Item_Product ? (int) $line->get_variation_id() : 0, 'name' => (string) $item['name'], 'quantity' => max( 1, isset( $item['quantity'] ) ? (int) $item['quantity'] : 1 ), ); // A fault report takes every line at the quantity ordered. It holds no // unit, so there is nothing for it to run out of — and dropping a line // because an earlier request holds it would record the fault against // the wrong item. $available = $no_units ? $entry['quantity'] : ( isset( $item['available'] ) ? (int) $item['available'] : 0 ); $all[] = $entry; if ( $available > 0 ) { $entry['quantity'] = $available; $items[] = $entry; continue; } // Nothing free on the line. It is left out of the request built below, // which is silent on its own: the operator is told about it after the // save and the omission is written into the history. $closed[] = array( 'name' => $entry['name'], 'line' => $entry['line_item_id'], 'holds' => self::line_holders( $claims, $entry['line_item_id'] ), ); } // A declaration is never refused because earlier requests already cover // the order. With nothing free it is recorded against the whole order, // and the lines it takes are the lines it reaches over. $reached = array(); if ( empty( $items ) ) { $items = $all; $reached = $no_units ? array() : wp_list_pluck( $all, 'line_item_id' ); $closed = array(); } if ( empty( $items ) ) { $this->redirect_with_error( __( 'That order has no items to record a declaration against.', 'careback' ) ); } // Offered, never required: the operator is copying a letter, not filling in // a form, and a fault report with no reason is still a fault report. It is // asked for because without it the claim is invisible in the reason figures. $reason = isset( $_POST['reason'] ) ? sanitize_key( wp_unslash( $_POST['reason'] ) ) : ''; $reason = array_key_exists( $reason, Reasons::all( $this->settings ) ) ? $reason : ''; if ( '' !== $reason && Reasons::is_fault( $reason, $this->settings ) ) { $flags = Requests_Repository::add_flag( $flags, Reasons::FAULT_FLAG ); } $window = (int) $this->settings->get( 'statutory_window_days', 14 ); // Whether the declaration was in time is measured by when the customer // sent it, not when it reached us: a letter posted on the last day is in // time however long it travels. The deadline itself still counts from // arrival. $handover = Order_Lookup::withdrawal_start( $order ); $declared = (int) strtotime( ( $sent ? $sent : $received ) . ' UTC' ); // The same rule the portal applies, from the same place: a route that // cannot carry a statutory declaration never gets one, whichever door the // declaration came through. // Whether it was in time is asked of the same helper the portal and the // deadline screens use. This branch counted in seconds while everything // else counts in whole days, so a declaration made late on the last day // was in time through one door and late through the other — and the door // it came through is not something the customer chose. // With statutory withdrawal switched off for the form (N-80) a declaration // is not made one by its date; a withdrawal the operator records stays one. $statutory = Deadlines::type_can_be_statutory( $flow ) && ( 'withdrawal' === $flow || Routes::withdrawal_offered( $this->settings ) ) && ( ! $handover || Deadlines::filed_in_time_from( $handover, $declared, $window ) ); // The requests this one reaches over, resolved the same way the portal // resolves them, so a declaration recorded by hand is gated at payout // exactly as one filed through the portal. $superseded = $reached ? $this->requests->claims_reached( $order->get_id(), $reached ) : array(); if ( $superseded ) { $flags = Requests_Repository::add_flag( $flags, Requests_Repository::SUPERSEDES_FLAG ); } $data = array( 'order_id' => $order->get_id(), 'order_number' => (string) $order->get_order_number(), 'email_hash' => Hash::email( $order->get_billing_email() ), 'customer_name' => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(), 'items' => $items, 'reason' => $reason, 'comment' => isset( $_POST['quote'] ) ? sanitize_textarea_field( wp_unslash( $_POST['quote'] ) ) : '', 'flags' => $flags, 'history_note' => $superseded ? '#' . implode( ', #', $superseded ) : '', 'channel' => isset( $_POST['channel'] ) ? sanitize_key( wp_unslash( $_POST['channel'] ) ) : 'email', 'delivery_state' => Order_Lookup::delivery_state_filtered( $order ), 'declaration_received_at' => $received, 'declaration_sent_at' => $sent ? $sent : $received, 'type' => $flow, 'original_type' => $statutory && 'withdrawal' !== $flow ? 'withdrawal' : '', 'status' => 'new', 'token_jti' => substr( 'manual-' . wp_generate_password( 24, false, false ), 0, 32 ), 'ip_hash' => '', // The browser of this request is the operator's, not the customer's. 'user_agent' => '', ); /** * Filters the row of a declaration recorded by hand before it is stored: * the place to add columns of one's own. The keys set here are stored as * set here whatever the filter returns for them, as with * `careback_submission_row`. See api/screen.md. * * @param array $data The row, as Requests_Repository::insert() takes it. * @param bool $statutory Whether it carries a statutory declaration: * its route can carry one and it was made in time. * @param \WC_Order $order The order. * @param Settings $settings Plugin settings. */ $data = array_merge( (array) apply_filters( self::MANUAL_ROW, $data, $statutory, $order, $this->settings ), $data ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_manual_entry_row, the final prefix. $id = $this->requests->insert( $data ); if ( ! $id ) { $this->redirect_with_error( __( 'The declaration could not be saved.', 'careback' ) ); } // Each earlier request keeps its status and its items and is only marked. foreach ( $superseded as $earlier ) { $this->requests->mark_superseded( $earlier, $id ); } $notice = array( 'page' => 'careback', 'careback_notice' => 'recorded', 'careback_recorded' => (int) $id ); if ( $closed ) { $this->requests->record_items_left_out( $id, self::left_out_note( $closed ) ); $notice['careback_left_out'] = (int) $id; } wp_safe_redirect( add_query_arg( $notice, admin_url( 'admin.php' ) ) ); exit; } /** * Ids of the requests holding units on one line, each once, in filing order. * * @param array $claims Map from Requests_Repository::claims_by_line(). * @param int $line Line the holders are wanted for. * @return array */ private static function line_holders( array $claims, $line ) { $line = (int) $line; if ( empty( $claims[ $line ] ) ) { return array(); } return array_values( array_unique( array_map( static function ( $claim ) { return (int) $claim['id']; }, $claims[ $line ] ) ) ); } /** * The lines left out of a manually recorded request, written as the item name * followed by the requests already holding it. * * @param array $closed Entries built in manual_entry(). */ private static function left_out_note( array $closed ) { $parts = array(); foreach ( $closed as $entry ) { $holds = ! empty( $entry['holds'] ) ? ' (#' . implode( ', #', $entry['holds'] ) . ')' : ''; $parts[] = (string) $entry['name'] . $holds; } return implode( '; ', $parts ); } /** * True when the given id names another request filed against the same order. * * @param array|null $row Request being closed. * @param int $ref Request the operator says it duplicates. */ private function duplicate_of( $row, $ref ) { $ref = absint( $ref ); if ( ! $ref || ! is_array( $row ) || (int) $row['id'] === $ref ) { return false; } $other = $this->requests->get( $ref ); return is_array( $other ) && (int) $other['order_id'] === (int) $row['order_id']; } /** Sends the operator to the confirmation step of a guarded status change. */ private function redirect_to_confirm( $id, $status, $error = '' ) { $args = array( 'page' => 'careback', 'careback_confirm' => (int) $id, 'careback_confirm_status' => (string) $status, ); if ( '' !== (string) $error ) { $args['careback_error'] = rawurlencode( $error ); } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } /** Sends the operator back to the list with a message. */ private function redirect_with_error( $message ) { wp_safe_redirect( add_query_arg( array( 'page' => 'careback', 'careback_error' => rawurlencode( $message ) ), admin_url( 'admin.php' ) ) ); exit; } /** Handles the proof-of-dispatch form on a request. */ public function dispatch_proof() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'careback' ) ); } // The id names the nonce (one per request): read for that alone and verified at once. $id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0; check_admin_referer( 'careback_dispatch_proof_' . $id ); $raw = isset( $_POST['proof_at'] ) ? sanitize_text_field( wp_unslash( $_POST['proof_at'] ) ) : ''; $when = $this->normalise_date( $raw, $id ); $this->requests->record_dispatch_proof( $id, isset( $_POST['reference'] ) ? sanitize_text_field( wp_unslash( $_POST['reference'] ) ) : '', $when ); wp_safe_redirect( add_query_arg( array( 'page' => 'careback', 'careback_notice' => 'proof' ), admin_url( 'admin.php' ) ) ); exit; } /** * Turns a date from a form into a timestamp for storage, refusing anything * before the request existed or in the future. The request is read only for * a date form_date() does not refuse on its own. */ private function normalise_date( $raw, $request_id, $floor = 0 ) { if ( '' === self::form_date( $raw, null, $floor ) ) { return ''; } $request = $this->requests->get( $request_id ); return self::form_date( $raw, $request ? (string) $request['created_at'] : null, $floor ); } /** * A day posted by a form of this screen, as stored: `Y-m-d 00:00:00` (UTC), * or '' when it is refused — empty, unreadable, in the future, more than a * day before `$floor`, or more than 30 days before the request was filed. * * Public (API 1, api/screen.md §9): a form an add-on adds to this screen * takes its dates by the same rule. * * @param string $raw Day as posted (`Y-m-d`). * @param string|null $created_at When the request was filed (`created_at`); null skips that check. * @param int $floor Earliest timestamp accepted, less a day of slack; 0 for none. * @return string */ public static function form_date( $raw, $created_at = null, $floor = 0 ) { $raw = trim( (string) $raw ); if ( '' === $raw ) { return ''; } $time = strtotime( $raw . ' 00:00:00 UTC' ); if ( ! $time || $time > time() ) { return ''; } if ( $floor && $time < $floor - DAY_IN_SECONDS ) { return ''; } // Goods are sometimes sent before the request is filed, so the floor is // generous; it only rules out dates that cannot belong to this order. if ( null !== $created_at ) { $created = strtotime( (string) $created_at . ' UTC' ); if ( $created && $time < $created - ( 30 * DAY_IN_SECONDS ) ) { return ''; } } return gmdate( 'Y-m-d H:i:s', $time ); } }