settings = $settings; $this->requests = $requests; $this->notifications = $notifications; } /** * @param \WC_Order $order Resolved from the signed ticket, never from client input. * @param array $payload Sanitised request payload. * @return array|\WP_Error */ public function create( \WC_Order $order, array $payload ) { // The name is composed server-side from the verified order (the form no // longer asks for it), so an empty value is not a user error. Fall back to // the order's billing email local-part rather than blocking the request. $payload['name'] = trim( (string) $payload['name'] ); if ( '' === $payload['name'] ) { $email = (string) $order->get_billing_email(); $payload['name'] = $email ? strstr( $email, '@', true ) : Texts::get( 'email_name_fallback', $this->settings ); } // Bound stored free text: the columns are finite and the values are shown in admin. $payload['name'] = mb_substr( $payload['name'], 0, 100 ); $payload['comment'] = mb_substr( (string) $payload['comment'], 0, 1000 ); // Recorded on the request; never a reason to refuse it. The withdrawal // right exists from the moment the contract is concluded. $delivery_state = Order_Lookup::delivery_state_filtered( $order ); $valid_ids = wp_list_pluck( Order_Lookup::items( $order ), 'line_item_id' ); if ( empty( $valid_ids ) ) { return new \WP_Error( 'careback_no_order_items', Texts::get( 'no_order_items', $this->settings ) ); } $requested = $this->normalise_selection( $payload ); $selected = array_values( array_intersect( array_keys( $requested ), $valid_ids ) ); if ( empty( $selected ) ) { return new \WP_Error( 'careback_no_items', Texts::get( 'no_items', $this->settings ) ); } // The route to validate is the one that will actually be filed, not // necessarily the one that was posted: an extension may file a posted // payload of its own on its own route (`careback_submission_route`), and // checking `flow` would then clear a different route from the one // recorded on the request. $filed = self::route( isset( $payload['flow'] ) ? $payload['flow'] : '', $payload ); // How long a customer may declare. Not how long the shop then has to pay: // until 0.66.0 one number stood for both. $window = (int) $this->settings->get( 'statutory_window_days', 14 ); // Units already claimed by other requests, so the same unit cannot be // returned or exchanged twice. $consumed = $this->requests->consumed_units( $order->get_id() ); // A statutory withdrawal declared while its window is open is measured // against the units held by statutory declarations only, so a unit held by // an exchange or a voluntary return stays open to it. Every other route // keeps counting every claim. $reaches_over = 'withdrawal' === $filed && Order_Lookup::window_open( $order, $window ); $spent = $reaches_over ? $this->requests->statutory_units( $order->get_id() ) : array(); // A route that takes no unit with it is measured against nothing at all. // Without this the portal offers a fault report on a unit an earlier // request holds — which is correct, the fault is in an item the customer // keeps — and the submission then answers `no_units_left`. The two halves // of the same rule lived in different classes and only one of them knew it. $unbounded = in_array( $filed, Routes::non_consuming_types(), true ); $items = array(); // Lines whose selection reaches into units another request holds. $reached = array(); foreach ( $order->get_items() as $item_id => $item ) { if ( ! in_array( (int) $item_id, $selected, true ) ) { continue; } $ordered = (int) $item->get_quantity(); $claimed = isset( $consumed[ (int) $item_id ] ) ? (int) $consumed[ (int) $item_id ] : 0; $free = max( 0, $ordered - $claimed ); $held = isset( $spent[ (int) $item_id ] ) ? (int) $spent[ (int) $item_id ] : 0; $remaining = $reaches_over ? max( 0, $ordered - $held ) : $free; if ( $unbounded ) { $remaining = $ordered; } // Every unit of this line is already spoken for. if ( $remaining < 1 ) { continue; } // Absent or zero quantity means one unit, never the whole line. $wanted = isset( $requested[ (int) $item_id ] ) ? absint( $requested[ (int) $item_id ] ) : 1; // A customer can claim part of a line, never more than the route // leaves open on it. $quantity = max( 1, min( $remaining, $wanted ) ); // A route that holds nothing reaches over nobody, so it is never // recorded as filed over an earlier request and never gates its payout. if ( $quantity > $free && ! $unbounded ) { $reached[] = (int) $item_id; } // The variation is kept beside the product so the per-variation return // figures count each size or colour on its own; 0 for a simple product. $items[] = array( 'line_item_id' => (int) $item_id, 'product_id' => (int) $item->get_product_id(), 'variation_id' => (int) $item->get_variation_id(), 'name' => Order_Lookup::item_label( $item ), 'quantity' => $quantity, ); } if ( empty( $items ) ) { return new \WP_Error( 'careback_no_units_left', Texts::get( 'no_units_left', $this->settings ) ); } // Every selected line is checked against that route on its own windows; // the first line the route is closed for refuses the request. foreach ( $items as $item ) { $line = Order_Lookup::line_routes( $order, $item['line_item_id'], $this->settings, $this->requests ); if ( empty( $line[ $filed ]['available'] ) ) { return new \WP_Error( 'careback_line_route_closed', isset( $line[ $filed ]['reason'] ) && '' !== $line[ $filed ]['reason'] ? $line[ $filed ]['reason'] : Texts::get( 'flow_none_available', $this->settings ) ); } } /** * Filters whether a submission is refused, once its items are known. * * Runs after the free part's own checks: the order has the lines, the * units are free, every selected line offers the route. A WP_Error * refuses the request with its message; anything else lets it through. * * @param \WP_Error|null $error Null while nothing refused it. * @param array $payload The submission as handed to create(). * @param \WC_Order $order The order, resolved from the ticket. * @param array $items Validated items, each { line_item_id, * product_id, variation_id, name, quantity }. * @param Settings $settings Plugin settings. */ $refused = apply_filters( self::VALIDATE_FILTER, null, $payload, $order, $items, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_submission_validate, the final prefix. if ( is_wp_error( $refused ) ) { return $refused; } $type = $filed; // A declaration filed here arrives as it is submitted; both timestamps are // the row's own creation time and are set by the repository. $now = current_time( 'mysql', true ); // Every route registered as able to carry one carries a statutory // declaration while the order is inside the statutory window; a // withdrawal carries one at any date. This drives original_type below, // and everything read off it later. A guarantee claim is not a withdrawal // and never becomes one. Without this it would be recorded as a // statutory declaration whenever it arrived inside the withdrawal window, // and would be read as owing a statutory refund for money nobody has // asked for and nobody owes yet. // With statutory withdrawal switched off (N-80) nothing filed becomes one. $statutory = 'withdrawal' === $type || ( Routes::withdrawal_offered( $this->settings ) && Deadlines::type_can_be_statutory( $type ) && ! Order_Lookup::past_withdrawal_window( $order, $window ) ); // Set when the chosen reason is one the merchant marked as blaming them. // Recorded on the request; it changes neither the type nor the deadline. $flags = Reasons::is_fault( $payload['reason'], $this->settings ) ? Reasons::FAULT_FLAG : ''; // Requests holding units this one reaches over, resolved only when a line // was actually filled beyond what was free. $superseded = $reached ? $this->requests->claims_reached( $order->get_id(), $reached ) : array(); if ( $superseded ) { $flags = Requests_Repository::add_flag( $flags, Requests_Repository::SUPERSEDES_FLAG ); } $row = array( 'order_id' => $order->get_id(), 'order_number' => (string) $order->get_order_number(), 'channel' => 'portal', 'delivery_state' => $delivery_state, 'declaration_received_at' => $now, 'declaration_sent_at' => $now, 'original_type' => $statutory && 'withdrawal' !== $type ? 'withdrawal' : '', 'email_hash' => Hash::email( $order->get_billing_email() ), 'customer_name' => $payload['name'], 'items' => $items, 'reason' => $payload['reason'], 'comment' => $payload['comment'], 'flags' => $flags, 'history_note' => $superseded ? '#' . implode( ', #', $superseded ) : '', 'type' => $type, 'status' => 'new', 'token_jti' => $payload['jti'], 'ip_hash' => Hash::ip( self::client_ip() ), 'user_agent' => self::client_user_agent(), ); // The consent with the returns policy the form asked for (N-12): the page // and the moment it last changed, recorded in the request's history. if ( ! empty( $payload['policy'] ) && is_array( $payload['policy'] ) ) { $row['policy'] = $payload['policy']; } /** * Filters the row stored for a new request. * * Adds columns of the extension's own; the keys set here are stored as * set here whatever the filter returns for them. * * @param array $row Row handed to Requests_Repository::insert(). * @param array $payload The submission as handed to create(). * @param \WC_Order $order The order. * @param array $items Validated items. * @param Settings $settings Plugin settings. */ $row = array_merge( (array) apply_filters( self::ROW_FILTER, $row, $payload, $order, $items, $this->settings ), $row ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_submission_row, the final prefix. $id = $this->requests->insert( $row ); if ( ! $id ) { return new \WP_Error( 'careback_store_failed', Texts::get( 'err_save_failed', $this->settings ) ); } // Each earlier request keeps its status and its items and is only marked. foreach ( $superseded as $earlier ) { $this->requests->mark_superseded( $earlier, $id ); } // The acknowledgement is written for the route that was filed, so the two // columns that hold it travel with the payload. $payload['type'] = $type; $payload['original_type'] = $statutory && 'withdrawal' !== $type ? 'withdrawal' : ''; // The moment the request was stored (N-72) and its receipt code, worked out // from the request as stored (N-70): the emails name both. $stored = $this->requests->get( $id ); $payload['received_at'] = $now; $payload['receipt_code'] = Receipt::code( is_array( $stored ) ? $stored : array_merge( $row, array( 'id' => $id ) ) ); $this->notifications->acknowledge( $id, $order, $items, $payload ); $this->notifications->notify_admin( $id, $order, $items, $payload ); return array( 'id' => $id ); } /** * Turns the portal's selection into a per-line count. * * The new form posts one entry per unit (`11:1`, `11:3`); the index is a * rendering artefact, so only the count per line is kept. Older cached * scripts still post items + quantities, which stays accepted. * * @return array line_item_id => units requested. */ private function normalise_selection( array $payload ) { $counts = array(); if ( ! empty( $payload['units'] ) && is_array( $payload['units'] ) ) { $seen = array(); foreach ( $payload['units'] as $unit ) { $unit = (string) $unit; if ( isset( $seen[ $unit ] ) || ! preg_match( '/^(\d+):(\d+)$/', $unit, $parts ) ) { continue; } $seen[ $unit ] = true; $line = (int) $parts[1]; $counts[ $line ] = isset( $counts[ $line ] ) ? $counts[ $line ] + 1 : 1; } return $counts; } $quantities = isset( $payload['quantities'] ) && is_array( $payload['quantities'] ) ? $payload['quantities'] : array(); foreach ( (array) $payload['items'] as $line ) { $line = absint( $line ); if ( ! $line ) { continue; } $counts[ $line ] = isset( $quantities[ $line ] ) ? absint( $quantities[ $line ] ) : 1; } return $counts; } /** * The route a submission files: the posted `flow` when the registry knows * it, else the statutory route, which may never be refused; then whatever a * `careback_submission_route` callback makes of it, held to the registry * the same way. * * @param string $flow The posted `flow`. * @param array $posted Everything posted with it, unsanitised — the REST * request's parameters, or the payload of create(). * @return string A registered route. */ public static function route( $flow, array $posted ) { $flow = (string) $flow; $flow = Routes::exists( $flow ) ? $flow : 'withdrawal'; /** * Filters the route a submission files. * * @param string $flow Posted route, resolved against the registry. * @param array $posted Everything posted with it, unsanitised. */ $route = (string) apply_filters( self::ROUTE_FILTER, $flow, $posted ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_submission_route, the final prefix. return Routes::exists( $route ) ? $route : 'withdrawal'; } private static function client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } /** * The browser the request was sent from (its user agent), as plain text cut * to the size of its column; '' when the request named none. * * @return string */ private static function client_user_agent() { $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return mb_substr( $agent, 0, Requests_Repository::USER_AGENT_MAX ); } }