managing_stock() ) { continue; } $current_stock = $product->get_stock_quantity(); if ( null === $current_stock || $current_stock < $quantity ) { $this->release_multiple( $reservations ); throw new \RuntimeException( esc_html( sprintf( 'Insufficient stock for product %s (available: %d, requested: %d)', $product->get_name(), $current_stock ?? 0, $quantity ) ) ); } wc_update_product_stock( $product, -$quantity, 'decrease' ); $reservations[] = array( 'product_id' => $product_id, 'variation_id' => $variation_id, 'quantity' => $quantity, ); } if ( empty( $reservations ) ) { return true; } set_transient( 'aicommerce_reservation_' . $session_id, $reservations, self::RESERVATION_TTL ); $active = get_option( 'aicommerce_active_reservations', array() ); $active[ $session_id ] = array( 'expires' => time() + self::RESERVATION_TTL, 'items' => $reservations, ); update_option( 'aicommerce_active_reservations', $active, false ); return true; } /** * Release a reservation — restore stock for a specific session. * * Called when a session expires or fails. NOT called on completion * (items are sold, stock stays reduced). * * @param string $session_id The agent session UUID. * * @return void */ public function release( string $session_id ): void { $reservations = get_transient( 'aicommerce_reservation_' . $session_id ); if ( ! $reservations || ! is_array( $reservations ) ) { // Fall back to active_reservations option. $active = get_option( 'aicommerce_active_reservations', array() ); if ( isset( $active[ $session_id ] ) ) { $reservations = $active[ $session_id ]['items'] ?? array(); } } if ( ! empty( $reservations ) ) { $this->release_multiple( $reservations ); } delete_transient( 'aicommerce_reservation_' . $session_id ); $active = get_option( 'aicommerce_active_reservations', array() ); unset( $active[ $session_id ] ); update_option( 'aicommerce_active_reservations', $active, false ); } /** * Mark a reservation as completed (items sold — do NOT restore stock). * * Simply removes the reservation tracking without restoring stock. * * @param string $session_id The agent session UUID. * * @return void */ public function mark_completed( string $session_id ): void { delete_transient( 'aicommerce_reservation_' . $session_id ); $active = get_option( 'aicommerce_active_reservations', array() ); unset( $active[ $session_id ] ); update_option( 'aicommerce_active_reservations', $active, false ); } /** * Release stock for multiple reservation items. * * @param array $reservations Array of reservation items. * * @return void */ public function release_multiple( array $reservations ): void { foreach ( $reservations as $item ) { $vid = (int) ( $item['variation_id'] ?? 0 ); $product = $vid ? wc_get_product( $vid ) : wc_get_product( (int) $item['product_id'] ); if ( $product && $product->managing_stock() ) { wc_update_product_stock( $product, (int) $item['quantity'], 'increase' ); } } } /** * Clean up expired reservations — restore stock and expire sessions. * * Called by WP Cron every 15 minutes. * * @return void */ public function cleanup_expired_reservations(): void { $active = get_option( 'aicommerce_active_reservations', array() ); $now = time(); if ( empty( $active ) ) { return; } $changed = false; foreach ( $active as $session_id => $data ) { if ( $data['expires'] >= $now ) { continue; } // Restore stock. if ( ! empty( $data['items'] ) ) { $this->release_multiple( $data['items'] ); } unset( $active[ $session_id ] ); $changed = true; // Update session status in DB. global $wpdb; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->update( $wpdb->prefix . 'aicommerce_agent_sessions', array( 'status' => 'expired' ), array( 'session_id' => $session_id, 'status' => 'pending', ), array( '%s' ), array( '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter delete_transient( 'aicommerce_reservation_' . $session_id ); } if ( $changed ) { update_option( 'aicommerce_active_reservations', $active, false ); } } /** * Prune old session and log records (90+ days). * * @return void */ public function prune_old_records(): void { global $wpdb; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( "DELETE FROM {$wpdb->prefix}aicommerce_agent_sessions WHERE status IN ('expired','failed','completed') AND created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( "DELETE FROM {$wpdb->prefix}aicommerce_agent_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } }