idempotency_key(); if ( null !== $idempotency_key && '' !== $idempotency_key ) { $existing = $this->find_by_idempotency_key( $table, $idempotency_key ); if ( null !== $existing ) { return $this->same_event( $existing, $event ); } } $uuid = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : $this->fallback_uuid(); $json = wp_json_encode( $event->payload() ); if ( ! is_string( $json ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Append-only writes target the plugin's own custom evidence table. $result = $wpdb->insert( $table, array( 'uuid' => $uuid, 'order_id' => $event->order_id(), 'event_type' => $event->event_type(), 'idempotency_key' => $idempotency_key, 'actor_type' => 'system', 'status' => 'recorded', 'payload_json' => $json, 'occurred_at' => $event->occurred_at(), 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( false !== $result ) { return true; } // A unique-key race is idempotent only if the winning row describes the // same immutable event. A conflicting reuse of the key must fail closed. if ( null !== $idempotency_key && '' !== $idempotency_key ) { $winner = $this->find_by_idempotency_key( $table, $idempotency_key ); return null !== $winner && $this->same_event( $winner, $event ); } return false; } public function for_order( int $order_id ): array { global $wpdb; $table = TableNames::get( 'events' ); if ( '' === $table || ! isset( $wpdb ) || $order_id <= 0 ) { return array(); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT event_type, order_id, payload_json, occurred_at, idempotency_key FROM {$table} WHERE order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table is an internal allowlisted identifier; immutable evidence reads must bypass mutable object caches. $events = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $payload = json_decode( (string) $row['payload_json'], true ); $events[] = new EvidenceEvent( (string) $row['event_type'], (int) $row['order_id'], is_array( $payload ) ? $payload : array(), (string) $row['occurred_at'], isset( $row['idempotency_key'] ) ? (string) $row['idempotency_key'] : null ); } return $events; } /** @return array|null */ private function find_by_idempotency_key( string $table, string $idempotency_key ): ?array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is resolved only from the internal TableNames allowlist; idempotency reads must see the authoritative custom table. $row = $wpdb->get_row( $wpdb->prepare( "SELECT event_type, order_id, payload_json FROM {$table} WHERE idempotency_key = %s LIMIT 1", $idempotency_key ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table is internal allowlisted name. // phpcs:enable return is_array( $row ) ? $row : null; } /** @param array $stored */ private function same_event( array $stored, EvidenceEvent $candidate ): bool { if ( (string) ( $stored['event_type'] ?? '' ) !== $candidate->event_type() || (int) ( $stored['order_id'] ?? 0 ) !== $candidate->order_id() ) { return false; } $payload = json_decode( (string) ( $stored['payload_json'] ?? '' ), true ); if ( ! is_array( $payload ) ) { return false; } return $this->normalize( $payload ) === $this->normalize( $candidate->payload() ); } /** @return mixed */ private function normalize( $value ) { if ( ! is_array( $value ) ) { return $value; } if ( array_is_list( $value ) ) { return array_map( array( $this, 'normalize' ), $value ); } $out = array(); foreach ( $value as $key => $item ) { $out[ (string) $key ] = $this->normalize( $item ); } ksort( $out, SORT_STRING ); return $out; } private function fallback_uuid(): string { $bytes = random_bytes( 16 ); $bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 ); $bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 ); $hex = bin2hex( $bytes ); return substr( $hex, 0, 8 ) . '-' . substr( $hex, 8, 4 ) . '-' . substr( $hex, 12, 4 ) . '-' . substr( $hex, 16, 4 ) . '-' . substr( $hex, 20 ); } }