settings = $settings; $this->submission = $submission; $this->requests = $requests; $this->events = $events; $this->security = $security instanceof Security ? $security : new Security( $settings ); } public function init() { add_action( 'rest_api_init', array( $this, 'routes' ) ); } public function routes() { register_rest_route( self::NS, '/lookup', array( 'methods' => 'POST', 'callback' => array( $this, 'lookup' ), 'permission_callback' => array( $this, 'permit_lookup' ), ) ); register_rest_route( self::NS, '/submit', array( 'methods' => 'POST', 'callback' => array( $this, 'submit' ), 'permission_callback' => array( $this, 'permit_submit' ), ) ); // The customer's own proof that they posted the goods back. Its own // route because it happens days after the request was filed: the // declaration goes in on Monday, the parcel is handed over on Thursday, // and until 0.69.0 the only way that fact could reach the desk was a // free-text email nobody's software could see. That is what left refunds // quietly late — the right to withhold had ended and the shop did not // know. register_rest_route( self::NS, '/dispatch-proof', array( 'methods' => 'POST', 'callback' => array( $this, 'dispatch_proof' ), 'permission_callback' => array( $this, 'permit_dispatch_proof' ), ) ); // A token minted per request, so a page cache serving hours-old HTML // cannot hand every visitor the same expired one. Deliberately its own // route rather than a header on the lookup: the script asks for it while // the customer is still reading the form, before anything is submitted. register_rest_route( self::NS, '/token', array( 'methods' => 'GET', 'callback' => array( $this, 'token' ), 'permission_callback' => array( $this, 'permit_token' ), ) ); } /* * Who may call the public routes. They serve the returns form and the * tracking page, which a customer uses without logging in, so there is no * user and no nonce to check: each route checks the plugin's own signed * token instead, here, before its handler runs. A refused token gets the * handler's own answer (the handler is called and refuses it before it * reads or changes anything), so the answer and what is recorded about the * attempt are the same whichever check meets it first. */ /** * /lookup: the signed render-time token (Security::time_token()). One that * is missing or not signed by this site is refused; an old or a fast one * goes on to the handler, which tells those apart. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function permit_lookup( \WP_REST_Request $request ) { if ( Security::TIME_BAD !== $this->security->time_token_verdict( (string) $request->get_param( 'time_token' ) ) ) { return true; } $refused = $this->lookup( $request ); return is_wp_error( $refused ) ? $refused : $this->generic_error(); } /** * /submit: the signed single-use ticket a successful lookup issued for one * order (Ticket). One that is missing, not signed by this site or expired * is refused. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function permit_submit( \WP_REST_Request $request ) { if ( false !== Ticket::verify( $request->get_param( 'ticket' ) ) ) { return true; } $refused = $this->submit( $request ); return is_wp_error( $refused ) ? $refused : $this->generic_error(); } /** * /dispatch-proof: the same ticket as /submit. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function permit_dispatch_proof( \WP_REST_Request $request ) { if ( false !== Ticket::verify( $request->get_param( 'ticket' ) ) ) { return true; } $refused = $this->dispatch_proof( $request ); return is_wp_error( $refused ) ? $refused : $this->generic_error(); } /** * /token: answers nothing about any order, so it needs no token; it is open * only while the returns form or the tracking page is switched on. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function permit_token( \WP_REST_Request $request ) { unset( $request ); $open = ! empty( $this->settings->get( 'portal_enabled' ) ) || ! empty( $this->settings->get( 'track_enabled' ) ); return $open && Plugin::woocommerce_active() ? true : new \WP_Error( 'careback_closed', '', array( 'status' => 404 ) ); } /** * One message for every failure: missing order, wrong email, malformed * input. Distinct messages would turn the endpoint into an oracle for * enumerating orders. */ /** * A freshly minted render-time token. * * Carries no-store headers of its own, because the whole point is to be the * one part of the portal a cache never keeps. Answers nothing about any * order, so it needs no permission check and leaks nothing when it is * called by anything but the portal. */ /** * A timing token, for whichever public page is switched on. * * Gated on the portal alone this route answered an empty string to a shop * running only the tracking page — and the tracking endpoint then refused * every lookup as bot timing, with nothing on either screen saying why. The * token proves how long a form was on screen; it belongs to neither desk. */ public function token( \WP_REST_Request $request ) { unset( $request ); $open = ! empty( $this->settings->get( 'portal_enabled' ) ) || ! empty( $this->settings->get( 'track_enabled' ) ); if ( ! $open || ! Plugin::woocommerce_active() ) { return new \WP_REST_Response( array( 'time_token' => '' ), 200 ); } $response = new \WP_REST_Response( array( 'time_token' => $this->security->time_token() ), 200 ); $response->header( 'Cache-Control', 'no-store, max-age=0' ); return $response; } private function generic_error() { return new \WP_Error( 'careback_not_found', Texts::get( 'err_no_match', $this->settings ), array( 'status' => 200 ) ); } /** * Answer to a form whose render-time token has aged out. * * Deliberately not generic_error(): the customer has done nothing wrong and * has to be told what to do about it, and unlike a wrong order number this * reveals nothing, because it is about the page and not about any order. * The attempt is not counted against the visitor either — the usual cause * is a page cache, and a customer who reloads and tries again must not walk * into an auto-block for it. */ private function stale_form_error() { return new \WP_Error( 'careback_form_stale', Texts::get( 'err_form_stale', $this->settings ), array( 'status' => 200 ) ); } /** * Answer for a new request on an order in a status none is filed from — * cancelled, failed, pending payment, refunded, a checkout draft. The * customer is sent to the shop, with the contact the portal prints under the * form. Given only to the holder of a ticket for that order. */ private function order_status_error() { return new \WP_Error( 'careback_order_status', $this->order_status_message(), array( 'status' => 200 ) ); } /** The message of order_status_error(), as plain text. */ private function order_status_message() { return sprintf( /* translators: %s: the shop's contact details, as printed under the form. */ __( 'For this order, please contact us: %s', 'careback' ), Frontend::contact_text( $this->settings ) ); } private function enabled() { return ! empty( $this->settings->get( 'portal_enabled' ) ) && Plugin::woocommerce_active(); } /** * Answer of the public endpoints on a site whose tables are not set up yet * (Database::ready()): given before anything is counted, logged or written, * since there is nowhere to write it. Says nothing about any order. 503, so * no cache keeps it; the pages show its message. * * @return \WP_Error */ public static function not_ready() { return new \WP_Error( 'careback_not_ready', self::not_ready_text(), array( 'status' => 503 ) ); } /** * What the returns form and the tracking page say, and their endpoints * answer, on a site whose tables are not set up yet. * * Not one of the texts a shop words in Settings → Texts: it can show only * on a site whose admin no one has opened yet, since the first admin page * a site loads sets its tables up — and that page is where the wording is * set. * * @return string Plain text. */ public static function not_ready_text() { return __( 'This form isn’t available yet. Please try again later.', 'careback' ); } public function lookup( \WP_REST_Request $request ) { if ( ! $this->enabled() ) { return $this->generic_error(); } if ( ! Database::ready() ) { return self::not_ready(); } $ip_hash = Hash::ip( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); $country = $this->security->country(); // The block is asked about first, ahead of every check that writes // something. It used to sit below the honeypot, so a blocked bot went on // being refused — correctly — while adding a row to the events table for // each attempt. The point of blocking a source is that it stops costing // the site anything. if ( $this->security->is_banned( $ip_hash ) ) { return new \WP_Error( 'careback_rate_limited', Texts::get( 'err_throttled', $this->settings ), array( 'status' => 429 ) ); } // Honeypot: a real browser leaves this empty. A filled one is a bot. if ( '' !== trim( (string) $request->get_param( 'company' ) ) ) { $this->events->log( 'honeypot', $ip_hash, $country, 'portal' ); $this->security->register_failure( $ip_hash ); return $this->generic_error(); } // Timing check. A token that is only old is treated apart from one that // is forged or impossibly fast: see Security::time_token_verdict(). $timing = $this->security->time_token_verdict( (string) $request->get_param( 'time_token' ) ); if ( Security::TIME_STALE === $timing ) { return $this->stale_form_error(); } if ( Security::TIME_OK !== $timing ) { $this->events->log( 'bot_timing', $ip_hash, $country, 'portal' ); $this->security->register_failure( $ip_hash ); return $this->generic_error(); } // An add-on's bot check (Security::VERIFY_FILTER); none lets it through. $bot_check = $this->security->bot_check( $request->get_param( 'bot_check' ), 'portal', isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); if ( is_wp_error( $bot_check ) ) { $this->events->log( 'bot_check_failed', $ip_hash, $country, 'portal' ); $this->security->register_failure( $ip_hash ); $message = $bot_check->get_error_message(); return '' === $message ? $this->generic_error() : new \WP_Error( 'careback_not_found', $message, array( 'status' => 200 ) ); } // Without the "#" the order e-mails print in front of the number, before // it is used for anything: "#1234" and "1234" are one order and one // per-order allowance. $number = Order_Lookup::clean_number( sanitize_text_field( (string) $request->get_param( 'order_number' ) ) ); $email = sanitize_email( (string) $request->get_param( 'email' ) ); $attempts = (int) $this->settings->get( 'lookup_max_attempts', 5 ); $window = (int) $this->settings->get( 'lookup_window_minutes', 15 ); if ( Rate_Limiter::too_many( 'ip', $ip_hash, $attempts, $window ) || Rate_Limiter::too_many( 'order', $number, $attempts, $window ) ) { // Noted once per source per window, not once per refused request: // the throttle exists to make a flood cheap, and a row written for // every attempt made it the one expensive thing left. if ( Rate_Limiter::first_in_window( 'throttle_noted', $ip_hash, $window ) ) { $this->events->log( 'rate_limited', $ip_hash, $country, 'portal' ); } return new \WP_Error( 'careback_rate_limited', Texts::get( 'err_throttled', $this->settings ), array( 'status' => 429 ) ); } Rate_Limiter::hit( 'ip', $ip_hash, $window ); if ( '' === $number || ! is_email( $email ) ) { return $this->generic_error(); } $order = Order_Lookup::find( $number ); // The same hashing work runs whether or not an order was found, so an // unknown order number cannot be told from a wrong email by response time. $matched = $order ? Order_Lookup::email_matches( $order, $email ) : Order_Lookup::absorb_email_check( $email ); if ( ! $matched ) { // Only a real order number counts against the per-order limit. Counting // unknown numbers would let anyone lock a customer out of their own // order by burning its allowance. if ( $order ) { Rate_Limiter::hit( 'order', $number, $window ); } self::lookup_result( 'portal', false ); $this->events->log( 'lookup_failed', $ip_hash, $country, 'portal' ); // Count the failure; may place a temporary block and log ip_banned. if ( $this->security->register_failure( $ip_hash ) ) { $this->events->log( 'ip_banned', $ip_hash, $country, 'portal' ); } return $this->generic_error(); } Rate_Limiter::clear( 'ip', $ip_hash ); Rate_Limiter::clear( 'order', $number ); // Reported here rather than on the page load: this is the point at which a // customer actually reached their own order, which is what "somebody used // the portal" means. A page view is somebody arriving, not being served. self::lookup_result( 'portal', true, $order ); // Outside the statutory withdrawal window a reason may be required; within // it, a withdrawal is reason-free by law, so it never is. // With statutory withdrawal switched off (N-80) there is no such window: the // route's own setting decides. $require_reason = Routes::withdrawal_offered( $this->settings ) && Order_Lookup::past_withdrawal_window( $order, $this->settings->get( 'statutory_window_days', 14 ) ); $delivery_state = Order_Lookup::delivery_state_filtered( $order ); // Each route with its own availability and, when closed, the reason the // portal renders in its place. A route reads as open while any line of the // order still offers it; the per-line detail travels with the items. $routes = Order_Lookup::combined_routes( $order, $this->settings, $this->requests ); $rows = (array) $this->requests->for_order( $order->get_id() ); $answer = array( 'ok' => true, 'order_number' => (string) $order->get_order_number(), 'items' => Order_Lookup::items( $order, $this->requests, $this->settings ), 'requests' => $this->awaiting_proof( $rows ), 'requireReason' => (bool) $require_reason, 'deliveryState' => $delivery_state, 'routes' => $routes, 'ticket' => Ticket::issue( $order->get_id(), $this->settings->get( 'ticket_ttl_minutes', 30 ) ), // For the check step (N-04): the day the order was placed and the name // on it. An order without a name says '' and the check step asks. 'placed' => self::placed( $order ), 'customerName' => self::order_name( $order ), ); // An order in a status no new request is filed from (cancelled, refunded, // pending payment …) still answers with its requests and a ticket: the // customer may have filed before it changed, and still has to be able to // read that request and tell us they posted the goods back. Only the // routes close, each carrying the message sending them to the shop; the // submission endpoint refuses the same orders on its own. if ( ! Order_Lookup::status_eligible( $order, $this->settings ) ) { $notice = $this->order_status_message(); foreach ( array_keys( $answer['routes'] ) as $route ) { $answer['routes'][ $route ]['available'] = false; $answer['routes'][ $route ]['reason'] = $notice; } $answer['notice'] = $notice; } /** * Filters the lookup's answer before it is sent to the portal. * * @param array $answer ok, order_number, items, requests, * requireReason, deliveryState, routes, ticket, * and notice when no new request can be filed. * @param \WC_Order $order The order the customer reached. * @param array $rows The order's requests, as the repository reads them. * @param Settings $settings Plugin settings. */ $answer = (array) apply_filters( self::LOOKUP_RESPONSE, $answer, $order, $rows, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_lookup_response, the final prefix. return rest_ensure_response( $answer ); } /** * The site's time zone at a moment, as a customer reads it: its abbreviation * ("CEST"), or for a site set to an offset "UTC+02:00" ("UTC" at +00:00), * as the confirmation email writes it (Emails::moment()). PHP names an * offset "GMT+0200" and a zone without a letter abbreviation "-03"; neither * is one. * * @param int $at Timestamp. * @return string */ public static function zone( $at ) { $zone = (string) wp_date( 'T', (int) $at ); if ( 1 === preg_match( '/^[A-Za-z]+$/', $zone ) ) { return $zone; } $offset = (string) wp_date( 'P', (int) $at ); return 'UTC' . ( '+00:00' === $offset ? '' : $offset ); } /** * The day an order was placed, in the site's date format; '' when it has no date. * * @param \WC_Order $order Order. * @return string */ public static function placed( \WC_Order $order ) { $created = $order->get_date_created(); return $created ? (string) wp_date( (string) get_option( 'date_format' ), $created->getTimestamp() ) : ''; } /** * The billing name on an order, first and last, trimmed; '' when it has none. * * @param \WC_Order $order Order. * @return string */ public static function order_name( \WC_Order $order ) { return trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ); } /** * Tells listeners how an order lookup on one of the public pages ended. * * Fired once per lookup that was checked against an order — found and * matched, or not. A lookup refused before that (a block, a throttle, a * honeypot, bot timing, a failed challenge) is a security event, which the * page records itself, and fires nothing. * * @param string $desk `portal` (the returns form) or `track` (the * tracking page). * @param bool $ok True when the customer reached their order. * @param \WC_Order|null $order The order reached; null when none was. * @return void */ public static function lookup_result( $desk, $ok, $order = null ) { /** * Fires once per order lookup on either public page. * * @param string $desk portal | track. * @param bool $ok True when the customer reached their order. * @param \WC_Order|null $order The order reached; null when none was. */ do_action( self::LOOKUP_RESULT, (string) $desk, (bool) $ok, $ok && $order instanceof \WC_Order ? $order : null ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_lookup_result, the final prefix. } /** * True when the customer may still tell us they posted the goods back. * * Three conditions, and each refuses for its own reason. The request has to * carry a statutory declaration, because the proof's whole effect is on the * statutory refund clock and no other route has one. Nothing may be stamped * once the goods are here, because the arrival already ended the withholding * and a later proof date would claim otherwise. And nothing may be stamped * twice — that check lives in the repository, which is where the operator's * path goes through as well. * * Deliberately not applied to the operator's form. A shop wanting the * tracking number of a voluntary return on the record is doing something * reasonable with its own data; a public endpoint accepting the same thing is * accepting input it has no reason to accept. * * One reader, two callers: the value the portal is given and the value the * endpoint enforces cannot drift apart if they are the same function. */ public static function customer_may_post_proof( array $row ) { $stamped = isset( $row['dispatch_proof_at'] ) ? (string) $row['dispatch_proof_at'] : ''; $arrived = isset( $row['goods_received_at'] ) ? (string) $row['goods_received_at'] : ''; $empty = static function ( $value ) { return '' === $value || '0000-00-00 00:00:00' === $value; }; return $empty( $stamped ) && $empty( $arrived ) && Requests_Repository::is_statutory( $row ); } /** * The requests of this order still waiting to hear that the goods were * posted back: what the portal offers its proof-of-posting field on. * * Only those, and only their number. What became of each request is told * by the status e-mails; a listener on `careback_lookup_response` may send * more. * * @param array $rows The order's requests. * @return array[] Each { id, canPost }. */ private function awaiting_proof( array $rows ) { $out = array(); foreach ( $rows as $row ) { $entry = array( 'id' => (int) $row['id'], 'canPost' => self::customer_may_post_proof( $row ), ); if ( $entry['canPost'] ) { $out[] = $entry; } } return $out; } /** * The customer telling us they have posted the goods back. * * Authorised by the same signed ticket the submit uses, and the request has * to belong to the order that ticket was issued for — otherwise anyone with * a ticket of their own could stamp evidence on somebody else's refund. * * Deliberately forgiving about what counts as a reference: the law binds * proof of return to no particular form, so a tracking number, a receipt * number or a line of text are all accepted. What matters is the date it * arrived, and that is stamped here rather than typed. */ public function dispatch_proof( \WP_REST_Request $request ) { if ( ! $this->enabled() ) { return $this->generic_error(); } if ( ! Database::ready() ) { return self::not_ready(); } $payload = Ticket::verify( $request->get_param( 'ticket' ) ); if ( ! $payload ) { return new \WP_Error( 'careback_expired', Texts::get( 'err_session_expired', $this->settings ), array( 'status' => 200 ) ); } $id = absint( $request->get_param( 'request_id' ) ); $row = $id ? $this->requests->get( $id ) : null; // The row must belong to the order this session was opened for. Without // this the ticket would authorise stamping any request in the shop. if ( ! $row || (int) $row['order_id'] !== (int) $payload['oid'] ) { return $this->generic_error(); } // The same three conditions the response builder answers `canPost` with, // re-asked here. The form guides, it does not authorise (ARCHITECTURE §6), // and until 0.69.4 these lived only in the render: the endpoint would // stamp a settled row, a voluntary return, or a request whose goods were // already back. None of that reached across an order or moved a deadline // that mattered, but a public endpoint that trusts its own form for what // it accepts is the shape of a bug rather than one instance of it. if ( ! self::customer_may_post_proof( $row ) ) { return $this->generic_error(); } $reference = sanitize_text_field( (string) $request->get_param( 'reference' ) ); $reference = trim( mb_substr( $reference, 0, 120 ) ); if ( '' === $reference ) { return new \WP_Error( 'careback_no_reference', Texts::get( 'err_dispatch_reference', $this->settings ), array( 'status' => 200 ) ); } // Write-once lives in the repository; a second attempt is answered as a // success from the customer's side, because from where they stand it is // one — we already know. if ( ! $this->requests->record_dispatch_proof( $id, $reference, '', 'customer' ) ) { return rest_ensure_response( array( 'ok' => true, 'message' => Texts::get( 'dispatch_already', $this->settings ), ) ); } return rest_ensure_response( array( 'ok' => true, 'message' => Texts::get( 'dispatch_thanks', $this->settings ), ) ); } public function submit( \WP_REST_Request $request ) { if ( ! $this->enabled() ) { return $this->generic_error(); } // Refused before the ticket is looked at: the customer keeps it. if ( ! Database::ready() ) { return self::not_ready(); } if ( '' !== trim( (string) $request->get_param( 'company' ) ) ) { return $this->generic_error(); } $ip_hash = Hash::ip( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); // A source blocked at the lookup step is refused here too, so the block // cannot be stepped around by holding a ticket issued before it was placed. if ( $this->security->is_banned( $ip_hash ) ) { return new \WP_Error( 'careback_rate_limited', Texts::get( 'err_throttled', $this->settings ), array( 'status' => 429 ) ); } // A ticket outlives the lookup throttle, so the write step carries its own // per-address cap. $window = (int) $this->settings->get( 'lookup_window_minutes', 15 ); if ( Rate_Limiter::too_many( 'submit_ip', $ip_hash, 20, $window ) ) { return new \WP_Error( 'careback_rate_limited', Texts::get( 'err_throttled', $this->settings ), array( 'status' => 429 ) ); } Rate_Limiter::hit( 'submit_ip', $ip_hash, $window ); // The submit carries the same render-time token; reject bot timing here // too, and tell an old form apart from a forged one as the lookup does. $timing = $this->security->time_token_verdict( (string) $request->get_param( 'time_token' ) ); if ( Security::TIME_STALE === $timing ) { return $this->stale_form_error(); } if ( Security::TIME_OK !== $timing ) { return $this->generic_error(); } $payload = Ticket::verify( $request->get_param( 'ticket' ) ); if ( ! $payload ) { return new \WP_Error( 'careback_expired', Texts::get( 'err_session_expired', $this->settings ), array( 'status' => 200 ) ); } // Replay is checked here but the ticket is spent only once the request is // about to be stored: a validation message must not cost the customer // their session and send them back to the order lookup. if ( Ticket::spent( $payload ) ) { return new \WP_Error( 'careback_replay', Texts::get( 'err_already_submitted', $this->settings ), array( 'status' => 200 ) ); } // Order identity comes from the signed ticket, never from the payload. $order = wc_get_order( (int) $payload['oid'] ); if ( ! $order instanceof \WC_Order ) { return $this->generic_error(); } // The lookup's status rule, asked again of the order as it stands now: it // may have been cancelled since the ticket was issued, and the form only // guides — this endpoint is what files the request. if ( ! Order_Lookup::status_eligible( $order, $this->settings ) ) { return $this->order_status_error(); } // The customer name is taken from the verified order, never from client // input: the order was already matched by number + email, so its billing // name is authoritative and the form does not ask for it. $name = self::order_name( $order ); if ( '' === $name ) { // Older orders or guest checkouts without a name fall back to a // client-supplied one if present, else the email local-part. $name = sanitize_text_field( (string) $request->get_param( 'name' ) ); if ( '' === $name ) { $email = (string) $order->get_billing_email(); $name = $email ? strstr( $email, '@', true ) : ''; } } $reason = sanitize_key( (string) $request->get_param( 'reason' ) ); $comment = sanitize_textarea_field( (string) $request->get_param( 'comment' ) ); // The route the customer picked. Validated here, never trusted from the // browser: an unknown value falls back to the statutory route, which is // the one that may never be refused. Resolved before the reason gate // below, because that gate now depends on which route was taken — and // resolved the way create() resolves it, so every gate below weighs the // route that ends up on the request, not only the one posted. $flow = Submission::route( sanitize_key( (string) $request->get_param( 'flow' ) ), (array) $request->get_params() ); // The route decides whether a reason is asked for and whether it has to be // answered. The reason itself is validated against the WHOLE list, never // against the subset the route offers today: a browser holding an older // script may post a reason that has since been taken off this route, and // refusing it would cost the customer a declaration over a dropdown. if ( '' !== $reason && ! array_key_exists( $reason, Reasons::all( $this->settings ) ) ) { $reason = ''; } if ( 'required' === Reasons::mode( $flow, $this->settings ) ) { // Each route words its own message: a fault report asks what is wrong // with the item, not for a reason to return it. $missing_reason = (string) Routes::property( $flow, 'reason_key' ); $missing_comment = (string) Routes::property( $flow, 'comment_key' ); if ( '' === $reason ) { return new \WP_Error( 'careback_reason_required', Texts::get( $missing_reason, $this->settings ), array( 'status' => 200 ) ); } // The description is required with the reason only on a route that // says so (the guarantee route): elsewhere a chosen reason is answer // enough. if ( Routes::property( $flow, 'comment_with_reason' ) && '' === trim( $comment ) ) { return new \WP_Error( 'careback_reason_required', Texts::get( $missing_comment, $this->settings ), array( 'status' => 200 ) ); } } // Outside the statutory withdrawal window a reason and comment are required // (a within-window withdrawal stays reason-free by law). Enforced here too, // not only in the browser, so the rule cannot be bypassed. if ( Routes::property( $flow, 'window_reason' ) && Routes::withdrawal_offered( $this->settings ) && Order_Lookup::past_withdrawal_window( $order, $this->settings->get( 'statutory_window_days', 14 ) ) ) { if ( '' === $reason || '' === trim( $comment ) ) { return new \WP_Error( 'careback_reason_required', Texts::get( 'err_reason_and_comment', $this->settings ), array( 'status' => 200 ) ); } } // The consent with the shop's returns policy, on a route it goes with and // while the shop asks for it (N-12): the form asks for the tick, and a // request without it is refused here with the same words. Never on the // withdrawal (Routes `policy_consent`). $policy = null; if ( Returns_Policy::asks( $flow, $this->settings ) ) { if ( ! rest_sanitize_boolean( $request->get_param( 'policy_accepted' ) ) ) { return new \WP_Error( 'careback_consent_required', Texts::get( 'need_consent', $this->settings ), array( 'status' => 200 ) ); } $policy = Returns_Policy::record( $this->settings ); } // Re-checked against the order as it stands now: a route that closed while // the form was open is refused, carrying that route's own reason. The // selected lines are checked separately in Submission::create(). $routes = Order_Lookup::combined_routes( $order, $this->settings, $this->requests ); if ( empty( $routes[ $flow ]['available'] ) ) { return new \WP_Error( 'careback_flow_closed', isset( $routes[ $flow ]['reason'] ) && '' !== $routes[ $flow ]['reason'] ? $routes[ $flow ]['reason'] : Texts::get( 'flow_none_available', $this->settings ), array( 'status' => 200 ) ); } $submission = array( 'items' => (array) $request->get_param( 'items' ), 'quantities' => (array) $request->get_param( 'quantities' ), 'units' => (array) $request->get_param( 'units' ), 'name' => $name, 'reason' => $reason, 'comment' => $comment, 'flow' => $flow, 'jti' => isset( $payload['jti'] ) ? sanitize_text_field( $payload['jti'] ) : '', ); if ( null !== $policy ) { $submission['policy'] = $policy; } /** * Filters the submission about to be filed, once the free part's own * gates passed and before the ticket is spent. * * Reads and checks what an extension posted alongside the form, and adds * it to the payload create() is handed. A WP_Error refuses the request * with its message and leaves the ticket usable. * * @param array $payload The submission. * @param \WP_REST_Request $request The submit request as posted. * @param \WC_Order $order The order, resolved from the ticket. * @param Settings $settings Plugin settings. */ $filed = apply_filters( self::PAYLOAD_FILTER, $submission, $request, $order, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_submission_payload, the final prefix. if ( is_wp_error( $filed ) ) { return $filed; } // Claimed atomically just before the write, so two concurrent submits // carrying the same ticket cannot both reach the insert. if ( ! Ticket::reserve( $payload ) ) { return new \WP_Error( 'careback_replay', Texts::get( 'err_already_submitted', $this->settings ), array( 'status' => 200 ) ); } $result = $this->submission->create( $order, (array) $filed ); if ( is_wp_error( $result ) ) { // A refusal must leave the customer able to correct and resubmit. Ticket::release( $payload ); return $result; } Ticket::consume( $payload ); return rest_ensure_response( $this->done( $result, $order ) ); } /** * The answer to a request filed: its reference, the moment it was received * in the shop's time zone, and the three lines of the done screen (N-04) — * the same moment the confirmation email names (N-72). * * @param array $result What Submission::create() returned (`id`). * @param \WC_Order $order The order. * @return array */ private function done( array $result, \WC_Order $order ) { $id = isset( $result['id'] ) ? (int) $result['id'] : 0; $row = $id ? $this->requests->get( $id ) : null; $at = is_array( $row ) && ! empty( $row['declaration_received_at'] ) ? strtotime( $row['declaration_received_at'] . ' UTC' ) : false; $at = false === $at ? time() : $at; $received = array( 'date' => (string) wp_date( (string) get_option( 'date_format' ), $at ), 'time' => (string) wp_date( 'H:i', $at ), 'zone' => self::zone( $at ), ); $reference = '#' . $id; $title = 'done_title'; if ( is_array( $row ) && Requests_Repository::is_statutory( $row ) ) { $title = 'done_title_withdrawal'; } elseif ( is_array( $row ) && 'return' === (string) $row['type'] ) { $title = 'done_title_return'; } return array( 'ok' => true, 'reference' => $reference, 'received' => $received, 'done' => array( 'title' => Texts::get( $title, $this->settings ), 'received' => Texts::format( 'done_received', $this->settings, array( $received['date'], $received['time'], $received['zone'], $reference ) ), 'receipt' => Texts::format( 'done_receipt', $this->settings, array( (string) $order->get_billing_email() ) ), ), ); } }