prefix . 'selectika_outbox'; $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table (event_id varchar(180) NOT NULL, generation varchar(36) NOT NULL, payload longtext NOT NULL, attempts int unsigned NOT NULL DEFAULT 0, available_at bigint unsigned NOT NULL, delivered_at bigint unsigned NOT NULL DEFAULT 0, PRIMARY KEY (event_id), KEY pending (delivered_at,available_at)) $charset;" ); } /** * Report pending and obsolete deliveries without exposing order payloads. * * @return array Outbox readiness counts. */ public static function status() { global $wpdb; $c = Selectika_Connection::get(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Authenticated diagnostics require current private outbox readiness; identifiers and values are prepared. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT SUM(delivered_at=0 AND generation=%s) AS pending, SUM(delivered_at=0 AND generation<>%s) AS obsolete, SUM(delivered_at=0 AND attempts>0 AND generation=%s) AS retrying FROM %i', $c['generation'], $c['generation'], $c['generation'], $wpdb->prefix . 'selectika_outbox' ), ARRAY_A ); return array( 'pending' => (int) ( $row['pending'] ?? 0 ), 'obsolete' => (int) ( $row['obsolete'] ?? 0 ), 'retrying' => (int) ( $row['retrying'] ?? 0 ), ); } /** * Record checkout consent and its connection generation on the order. * * @param WC_Order $order Native order object. */ public function capture_consent( $order ) { if ( Selectika_Connection::active() ) { $c = Selectika_Connection::get(); $order->update_meta_data( '_selectika_connection_generation', $c['generation'] ); $order->update_meta_data( '_selectika_analytics_consent', Selectika_Events::consent() ? 'yes' : 'no' ); } } /** * Queue a payment-complete event after WooCommerce confirms payment. * * @param int $id Native object ID. */ public function paid( $id ) { $this->queue( wc_get_order( $id ), 'order_paid' ); } /** * Queue a cancellation event for a consented order. * * @param int $id Native object ID. */ public function cancelled( $id ) { $this->queue( wc_get_order( $id ), 'order_cancelled' ); } /** * Queue an individual refund event with a stable refund identity. * * @param int $id Native object ID. * @param int $refund_id Native refund ID. */ public function refunded( $id, $refund_id ) { $this->queue( wc_get_order( $id ), 'order_refunded', wc_get_order( $refund_id ) ); } /** * Build the minimal commerce event without customer personal information. * * @param WC_Order $order Native order object. * @param string $name Event or attribute name. * @param WC_Order_Refund|null $refund Optional native refund object. */ public static function payload( $order, $name, $refund = null ) { $c = Selectika_Connection::get(); $event_id = 'woo:' . $c['site_id'] . ':order:' . $order->get_id() . ':' . ( $refund ? 'refund:' . $refund->get_id() : ( 'order_paid' === $name ? 'paid' : 'cancelled' ) ); $items = array(); $source = $refund ? $refund : $order; foreach ( $source->get_items( 'line_item' ) as $line ) { $product = wc_get_product( $line->get_product_id() ); $quantity = abs( (float) $line->get_quantity() ); if ( ! $product || $quantity <= 0 ) { continue; } $item = array( 'item_id' => Selectika_Connection::item_id( $product ), 'product_id' => (string) $line->get_product_id(), 'quantity' => $quantity, 'price' => wc_format_decimal( abs( (float) $line->get_total() ) / $quantity, wc_get_price_decimals() ), ); if ( $line->get_variation_id() > 0 ) { $item['variation_id'] = (string) $line->get_variation_id(); } $items[] = $item; } $payload = array( 'schema_version' => 1, 'retailer' => $c['retailer'], 'site_id' => $c['site_id'], 'connection_generation' => $c['generation'], 'event_id' => $event_id, 'event_name' => $name, 'occurred_at' => gmdate( 'c' ), 'order_id' => (string) $order->get_id(), 'currency' => $order->get_currency(), 'value' => wc_format_decimal( $refund ? abs( (float) $refund->get_amount() ) : $order->get_total(), wc_get_price_decimals() ), 'items' => $items, 'analytics_consent' => 'yes' === $order->get_meta( '_selectika_analytics_consent' ), ); if ( $refund ) { $payload['refund_id'] = (string) $refund->get_id(); } return $payload; } /** * Persist one event idempotently before scheduling delivery. * * @param WC_Order $order Native order object. * @param string $name Event or attribute name. * @param WC_Order_Refund|null $refund Optional native refund object. */ private function queue( $order, $name, $refund = null ) { global $wpdb; if ( ! $order || ! Selectika_Connection::active( 'orders' ) || 'yes' !== $order->get_meta( '_selectika_analytics_consent' ) || Selectika_Connection::get()['generation'] !== $order->get_meta( '_selectika_connection_generation' ) ) { return; } if ( 'order_refunded' === $name && ! $refund ) { return; } $payload = self::payload( $order, $name, $refund ); $table = $wpdb->prefix . 'selectika_outbox'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Native INSERT IGNORE is required for durable idempotency; private outbox rows are never cached. $result = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (event_id,generation,payload,available_at) VALUES (%s,%s,%s,%d)', $table, $payload['event_id'], $payload['connection_generation'], wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), time() ) ); if ( false === $result ) { update_option( 'selectika_orders_error', 'outbox_storage_failed', false ); return; } if ( ! wp_next_scheduled( 'selectika_orders_send' ) ) { wp_schedule_single_event( time() + 5, 'selectika_orders_send' ); } } /** * Deliver due events with signed requests and durable retry state. */ public function send() { global $wpdb; if ( ! Selectika_Connection::active( 'orders' ) ) { return; } $c = Selectika_Connection::get(); $secret = Selectika_Connection::order_secret(); if ( ! $secret || ! $c['order_endpoint'] ) { update_option( 'selectika_orders_error', 'orders_not_configured', false ); return; } $lock = Selectika_Connection::acquire_lock( 'selectika_orders_lock', 120 ); if ( ! $lock ) { return; } $table = $wpdb->prefix . 'selectika_outbox'; try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private outbox reads/writes require current durable state for retries and deduplication; all identifiers and values are prepared. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE delivered_at=0 AND available_at<=%d AND generation=%s ORDER BY available_at LIMIT 10', $table, time(), $c['generation'] ), ARRAY_A ); foreach ( $rows as $row ) { $current = Selectika_Connection::refresh(); if ( ! Selectika_Connection::active( 'orders' ) || $current['generation'] !== $c['generation'] || $current['order_secret'] !== $c['order_secret'] ) { break; } $timestamp = (string) time(); $response = wp_safe_remote_post( $c['order_endpoint'], array( 'timeout' => 8, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'X-Selectika-Site' => $c['site_id'], 'X-Selectika-Generation' => $c['generation'], 'X-Selectika-Timestamp' => $timestamp, 'X-Selectika-Signature' => hash_hmac( 'sha256', $timestamp . '.' . $row['payload'], $secret ), ), 'body' => $row['payload'], 'limit_response_size' => 4096, ) ); $ack = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ), true ); $success = ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) >= 200 && wp_remote_retrieve_response_code( $response ) < 300 && is_array( $ack ) && true === ( $ack['accepted'] ?? false ) && ( $ack['event_id'] ?? null ) === $row['event_id']; if ( $success ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist delivery/retry state directly; outbox data is never cached. $wpdb->update( $table, array( 'delivered_at' => time() ), array( 'event_id' => $row['event_id'] ) ); update_option( 'selectika_orders_last_success', gmdate( 'c' ), false ); delete_option( 'selectika_orders_error' ); } else { $attempts = (int) $row['attempts'] + 1; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist delivery/retry state directly; outbox data is never cached. $wpdb->update( $table, array( 'attempts' => $attempts, 'available_at' => time() + min( HOUR_IN_SECONDS, 30 * ( 2 ** min( $attempts, 7 ) ) ), ), array( 'event_id' => $row['event_id'] ) ); update_option( 'selectika_orders_error', 'order_delivery_failed', false ); } } // Retain deduplication markers; scrub delivered payloads after 90 days. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private outbox reads/writes require current durable state for retries and deduplication; all identifiers and values are prepared. $wpdb->query( $wpdb->prepare( "UPDATE %i SET payload='' WHERE delivered_at>0 AND delivered_at<%d", $table, time() - 90 * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private outbox reads/writes require current durable state for retries and deduplication; all identifiers and values are prepared. $pending = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE delivered_at=0 AND generation=%s', $table, $c['generation'] ) ); if ( $pending && Selectika_Connection::active( 'orders' ) && ! wp_next_scheduled( 'selectika_orders_send' ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'selectika_orders_send' ); } } finally { Selectika_Connection::release_lock( 'selectika_orders_lock', $lock ); } } }