self::META_REMOTE_ID, 'value' => (string) $query_vars[ self::QUERY_VAR_REMOTE_ID ], 'compare' => '=', ); } return $query; } /** * Full URL to paste into the Fabrixa dashboard. * * @return string */ public static function url() { return rest_url( self::REST_NAMESPACE . self::REST_ROUTE ); } public function register_route() { register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, array( 'methods' => 'POST', 'callback' => array( $this, 'handle' ), 'permission_callback' => array( $this, 'verify_signature' ), ) ); } /* ---------- Authentication ---------- */ /** * @param WP_REST_Request $request Incoming request. * @return true|WP_Error */ public function verify_signature( $request ) { $secret = Fabrixa_Settings::webhook_secret(); if ( empty( $secret ) ) { Fabrixa_API::log( 'Webhook rejected: no shared secret configured.', 'error' ); return new WP_Error( 'fabrixa_webhook_unconfigured', __( 'Fabrixa webhook secret is not configured.', 'fabrixa-studio' ), array( 'status' => 401 ) ); } $provided = $request->get_header( self::SIGNATURE_HEADER ); if ( empty( $provided ) ) { Fabrixa_API::log( 'Webhook rejected: signature header missing.', 'warning' ); return new WP_Error( 'fabrixa_webhook_unsigned', __( 'Missing webhook signature.', 'fabrixa-studio' ), array( 'status' => 401 ) ); } // Sign the raw body: re-encoding the parsed JSON would change the bytes. $expected = base64_encode( hash_hmac( 'sha256', $request->get_body(), $secret, true ) ); if ( ! hash_equals( $expected, trim( $provided ) ) ) { Fabrixa_API::log( 'Webhook rejected: signature mismatch.', 'warning' ); return new WP_Error( 'fabrixa_webhook_bad_signature', __( 'Invalid webhook signature.', 'fabrixa-studio' ), array( 'status' => 401 ) ); } return true; } /* ---------- Handling ---------- */ /** * @param WP_REST_Request $request Verified request. * @return WP_REST_Response */ public function handle( $request ) { $payload = json_decode( $request->get_body(), true ); if ( ! is_array( $payload ) ) { return new WP_REST_Response( array( 'success' => false, 'error' => 'invalid_json' ), 400 ); } $topic = $request->get_header( 'x-webhook-topic' ); if ( empty( $topic ) ) { $topic = isset( $payload['topic'] ) ? $payload['topic'] : ( isset( $payload['event'] ) ? $payload['event'] : '' ); } // Some senders nest the entity; accept both shapes. $data = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload; $order = $this->find_order( $data ); if ( ! $order ) { // 200 on purpose: this order is not ours, so retrying will never help. Fabrixa_API::log( sprintf( 'Webhook (%s) had no matching local order. Fabrixa id: %s, number: %s.', $topic ? $topic : 'unknown topic', isset( $data['id'] ) ? $data['id'] : '—', isset( $data['number'] ) ? $data['number'] : '—' ), 'warning' ); return new WP_REST_Response( array( 'success' => true, 'matched' => false ), 200 ); } $this->apply_state( $order, $data, $topic ); /** * Fires after a verified Fabrixa webhook has been applied to an order. * * @since 0.2.0 * * @param WC_Order $order Local order. * @param array $data Remote order entity. * @param string $topic Event topic. */ do_action( 'fabrixa_webhook_received', $order, $data, $topic ); return new WP_REST_Response( array( 'success' => true, 'matched' => true, 'order_id' => $order->get_id() ), 200 ); } /** * Resolve the local order: first by the Fabrixa id recorded from an earlier * event, then by the order number, which the channel took from WooCommerce. * * @param array $data Remote order entity. * @return WC_Order|null */ private function find_order( array $data ) { if ( ! empty( $data['id'] ) ) { $args = array( 'limit' => 1, 'return' => 'ids', 'status' => 'any', ); if ( self::hpos_enabled() ) { $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => self::META_REMOTE_ID, 'value' => (string) $data['id'], 'compare' => '=', ), ); } else { $args[ self::QUERY_VAR_REMOTE_ID ] = (string) $data['id']; } $orders = wc_get_orders( $args ); if ( ! empty( $orders ) ) { $order = wc_get_order( $orders[0] ); /* * Confirm the match rather than trusting the query. An argument a * datastore does not understand is ignored rather than refused, and * an ignored filter here would return the newest order instead of * none — applying a webhook to somebody else's order. */ if ( $order && (string) $order->get_meta( self::META_REMOTE_ID ) === (string) $data['id'] ) { return $order; } } } if ( ! empty( $data['number'] ) ) { /** * Resolve a Fabrixa order number to a local order id. * * Default WooCommerce order numbers are ids; a plugin that changes * that scheme hooks this to resolve its own. * * @since 0.2.0 * * @param int $order_id Resolved order id, 0 when unresolved. * @param string $number Order number as Fabrixa sent it. */ $order_id = apply_filters( 'fabrixa_resolve_order_number', 0, (string) $data['number'] ); if ( ! $order_id && is_numeric( $data['number'] ) ) { $order_id = (int) $data['number']; } $order = $order_id ? wc_get_order( $order_id ) : null; if ( $order && (string) $order->get_order_number() === (string) $data['number'] ) { return $order; } } return null; } /** * Store the remote state, record tracking, and complete the order once * Fabrixa reports it shipped. * * @param WC_Order $order Local order. * @param array $data Remote order entity. * @param string $topic Event topic. */ private function apply_state( $order, array $data, $topic ) { $fulfillment = isset( $data['fulfillment_status'] ) ? (string) $data['fulfillment_status'] : ''; $remote = isset( $data['status'] ) ? (string) $data['status'] : ''; $previous = (string) $order->get_meta( self::META_FULFILLMENT ); if ( '' !== $remote ) { $order->update_meta_data( self::META_REMOTE_STATE, $remote ); } if ( '' !== $fulfillment ) { $order->update_meta_data( self::META_FULFILLMENT, $fulfillment ); } // Record the Fabrixa ids the first time an event names them. Orders reach // Fabrixa through the channel connection, so this is normally the first // the shop hears of them; later events then match on the id directly. if ( ! empty( $data['id'] ) && ! $order->get_meta( self::META_REMOTE_ID ) ) { $order->update_meta_data( self::META_REMOTE_ID, (string) $data['id'] ); } if ( ! empty( $data['number'] ) && ! $order->get_meta( self::META_REMOTE_NUM ) ) { $order->update_meta_data( self::META_REMOTE_NUM, (string) $data['number'] ); } $tracking = $this->collect_tracking( $data ); if ( $tracking ) { $order->update_meta_data( self::META_TRACKING, $tracking ); } // Only note real changes, so repeated deliveries don't spam the order. if ( $fulfillment !== $previous || $tracking ) { $note = sprintf( /* translators: 1: event topic, 2: remote status, 3: fulfilment status. */ __( 'Fabrixa update (%1$s): status %2$s, fulfilment %3$s.', 'fabrixa-studio' ), $topic ? $topic : 'order.updated', '' !== $remote ? $remote : '—', '' !== $fulfillment ? $fulfillment : '—' ); foreach ( $tracking as $entry ) { $note .= "\n" . sprintf( /* translators: 1: carrier/method name, 2: tracking number. */ __( 'Tracking — %1$s: %2$s', 'fabrixa-studio' ), $entry['method'] ? $entry['method'] : '—', $entry['number'] ? $entry['number'] : '—' ); if ( $entry['url'] ) { $note .= ' ' . $entry['url']; } } $order->add_order_note( $note ); } $order->save(); if ( $this->is_fulfilled( $fulfillment ) && $order->has_status( self::completable_statuses() ) ) { /** * Whether a Fabrixa-fulfilled order should be marked complete locally. * * @since 0.2.0 * * @param bool $complete Default true. * @param WC_Order $order Local order. * @param array $data Remote order entity. */ if ( apply_filters( 'fabrixa_complete_order_on_fulfillment', true, $order, $data ) && ! $order->has_status( 'completed' ) ) { $order->update_status( 'completed', __( 'Fabrixa reported the order as fulfilled.', 'fabrixa-studio' ) ); } } } /* ---------- Admin ---------- */ /** * Show what Fabrixa has reported about this order on the edit screen. * * @param WC_Order $order Order being edited. */ public function render_admin_status( $order ) { $remote_id = $order->get_meta( self::META_REMOTE_ID ); $remote = $order->get_meta( self::META_REMOTE_STATE ); $fulfillment = $order->get_meta( self::META_FULFILLMENT ); if ( ! $remote_id && ! $remote && ! $fulfillment ) { return; } ?>
: