eventbrite-for-the-events-c.../includes/class-eb4tec-event-sync.php
Laurence Horrocks-Barlow f3bc795d9a Initial release of Eventbrite for The Events Calendar (v1.0.0)
Bidirectional sync between Eventbrite and The Events Calendar, with
WooCommerce ticket purchasing that bypasses Eventbrite's processing
fees by registering buyers as free attendees via API. Includes venue/
organizer sync, QR code ticket generation, attendee management with
CSV export, scheduled sync via WP-Cron, and real-time Eventbrite
webhooks.
2026-05-17 08:48:04 +01:00

483 lines
16 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class EB4TEC_Event_Sync {
public function __construct(
private readonly EB4TEC_API_Client $api,
private readonly EB4TEC_Venue_Sync $venue_sync,
private readonly EB4TEC_Organizer_Sync $organizer_sync,
private readonly EB4TEC_Ticket_Manager $ticket_manager,
private readonly EB4TEC_WooCommerce $woocommerce,
) {
add_action( 'add_meta_boxes', [ $this, 'add_meta_box' ] );
add_action( 'save_post_tribe_events', [ $this, 'on_save_post' ], 20, 3 );
}
// ---------------------------------------------------------------------------
// Full sync entry point
// ---------------------------------------------------------------------------
public function run_full_sync(): array {
$pulled = 0;
$pushed = 0;
$errors = [];
$direction = get_option( 'eb4tec_sync_direction', 'both' );
if ( in_array( $direction, [ 'both', 'eb_to_tec' ], true ) ) {
$pull_result = $this->pull_from_eventbrite();
if ( is_wp_error( $pull_result ) ) {
$errors[] = $pull_result->get_error_message();
} else {
$pulled = $pull_result;
}
}
if ( in_array( $direction, [ 'both', 'tec_to_eb' ], true ) ) {
$push_result = $this->push_to_eventbrite();
if ( is_wp_error( $push_result ) ) {
$errors[] = $push_result->get_error_message();
} else {
$pushed = $push_result;
}
}
return [ 'pulled' => $pulled, 'pushed' => $pushed, 'errors' => count( $errors ) ];
}
// ---------------------------------------------------------------------------
// Pull: Eventbrite → TEC
// ---------------------------------------------------------------------------
public function pull_from_eventbrite(): int|WP_Error {
$org_id = (string) get_option( 'eb4tec_org_id', '' );
if ( empty( $org_id ) ) {
return new WP_Error( 'eb4tec_no_org', __( 'Eventbrite organization ID not configured.', 'eb4tec' ) );
}
$page = 1;
$pulled = 0;
do {
$response = $this->api->get_organization_events( $org_id, $page );
if ( is_wp_error( $response ) ) {
return $response;
}
$events = $response['events'] ?? [];
$pagination = $response['pagination'] ?? [];
foreach ( $events as $eb_event ) {
$result = $this->pull_event_from_data( $eb_event );
if ( ! is_wp_error( $result ) && $result > 0 ) {
$pulled++;
}
}
$has_more = ! empty( $pagination['has_more_items'] );
$page++;
} while ( $has_more );
return $pulled;
}
public function pull_event( string $eb_event_id ): int|WP_Error {
$data = $this->api->get_event( $eb_event_id );
if ( is_wp_error( $data ) ) {
return $data;
}
return $this->pull_event_from_data( $data );
}
private function pull_event_from_data( array $eb_event ): int|WP_Error {
$eb_id = $eb_event['id'] ?? '';
if ( empty( $eb_id ) ) {
return new WP_Error( 'eb4tec_no_id', __( 'Eventbrite event has no ID.', 'eb4tec' ) );
}
$existing_id = $this->find_tec_event( $eb_id );
// Skip events the user has marked to exclude from sync.
if ( $existing_id && get_post_meta( $existing_id, '_eb4tec_exclude_sync', true ) ) {
return $existing_id;
}
$post_args = $this->eb_event_to_tec_args( $eb_event );
if ( $existing_id ) {
$post_args['ID'] = $existing_id;
// Remove status from update args — don't override draft if admin changed it.
unset( $post_args['post_status'] );
$post_id = wp_update_post( $post_args, true );
} else {
$post_id = wp_insert_post( $post_args, true );
}
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
$this->update_tec_from_eb( $post_id, $eb_event );
// Sync venue.
if ( get_option( 'eb4tec_sync_venues' ) && ! empty( $eb_event['venue_id'] ) ) {
$venue_post_id = $this->venue_sync->pull_venue( $eb_event['venue_id'] );
if ( ! is_wp_error( $venue_post_id ) && $venue_post_id > 0 ) {
update_post_meta( $post_id, '_EventVenueID', $venue_post_id );
}
}
// Sync organizer.
if ( get_option( 'eb4tec_sync_organizers' ) && ! empty( $eb_event['organizer_id'] ) ) {
$org_post_id = $this->organizer_sync->pull_organizer( $eb_event['organizer_id'] );
if ( ! is_wp_error( $org_post_id ) && $org_post_id > 0 ) {
update_post_meta( $post_id, '_EventOrganizerID', $org_post_id );
}
}
// Ensure WooCommerce product exists.
$this->woocommerce->sync_event_product( $post_id );
// Ensure hidden ticket class exists on Eventbrite.
$capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true );
if ( $capacity > 0 ) {
$this->ticket_manager->ensure_wp_ticket_class( $eb_id, $post_id, $capacity );
}
return $post_id;
}
// ---------------------------------------------------------------------------
// Push: TEC → Eventbrite
// ---------------------------------------------------------------------------
public function push_to_eventbrite(): int|WP_Error {
// Find events that need pushing: no EB ID yet, or flagged for push on last save.
$query = new WP_Query( [
'post_type' => 'tribe_events',
'post_status' => [ 'publish', 'draft' ],
'posts_per_page' => 100,
'fields' => 'ids',
'meta_query' => [
'relation' => 'OR',
[
'key' => '_eb4tec_event_id',
'compare' => 'NOT EXISTS',
],
[
'key' => '_eb4tec_push_on_save',
'value' => '1',
],
],
] );
$pushed = 0;
foreach ( $query->posts as $post_id ) {
if ( get_post_meta( $post_id, '_eb4tec_exclude_sync', true ) ) {
continue;
}
$result = $this->push_event( $post_id );
if ( ! is_wp_error( $result ) ) {
$pushed++;
}
// Clear the flag after processing.
delete_post_meta( $post_id, '_eb4tec_push_on_save' );
}
return $pushed;
}
public function push_event( int $post_id ): string|WP_Error {
$org_id = (string) get_option( 'eb4tec_org_id', '' );
if ( empty( $org_id ) ) {
return new WP_Error( 'eb4tec_no_org', __( 'Eventbrite organization ID not configured.', 'eb4tec' ) );
}
$eb_event_id = (string) get_post_meta( $post_id, '_eb4tec_event_id', true );
$body = $this->tec_post_to_eb_body( $post_id );
if ( $eb_event_id ) {
$result = $this->api->update_event( $eb_event_id, $body );
} else {
$result = $this->api->create_event( $body );
}
if ( is_wp_error( $result ) ) {
return $result;
}
$new_eb_id = $result['id'] ?? '';
if ( empty( $new_eb_id ) ) {
return new WP_Error( 'eb4tec_no_event_id', __( 'Eventbrite did not return an event ID.', 'eb4tec' ) );
}
update_post_meta( $post_id, '_eb4tec_event_id', sanitize_text_field( $new_eb_id ) );
update_post_meta( $post_id, '_eb4tec_eb_url', esc_url_raw( $result['url'] ?? '' ) );
update_post_meta( $post_id, '_eb4tec_last_synced', time() );
// Auto-publish if configured.
if ( get_option( 'eb4tec_auto_publish' ) && get_post_status( $post_id ) === 'publish' ) {
$this->api->publish_event( $new_eb_id );
}
// Push venue.
if ( get_option( 'eb4tec_sync_venues' ) ) {
$venue_id = (int) get_post_meta( $post_id, '_EventVenueID', true );
if ( $venue_id ) {
$this->venue_sync->push_venue( $venue_id );
}
}
// Push organizer.
if ( get_option( 'eb4tec_sync_organizers' ) ) {
$org_post_id = (int) get_post_meta( $post_id, '_EventOrganizerID', true );
if ( $org_post_id ) {
$this->organizer_sync->push_organizer( $org_post_id );
}
}
// Create WC product if not already linked.
$this->woocommerce->sync_event_product( $post_id );
// Create hidden ticket class on Eventbrite.
$capacity = (int) get_post_meta( $post_id, '_eb4tec_capacity', true );
if ( $capacity > 0 ) {
$this->ticket_manager->ensure_wp_ticket_class( $new_eb_id, $post_id, $capacity );
}
return $new_eb_id;
}
// ---------------------------------------------------------------------------
// Meta box
// ---------------------------------------------------------------------------
public function add_meta_box(): void {
add_meta_box(
'eb4tec_event_meta',
__( 'Eventbrite Sync', 'eb4tec' ),
[ $this, 'render_meta_box' ],
'tribe_events',
'side',
'default'
);
}
public function render_meta_box( WP_Post $post ): void {
wp_nonce_field( 'eb4tec_event_meta_save', '_eb4tec_event_meta_nonce' );
$eb_id = esc_attr( get_post_meta( $post->ID, '_eb4tec_event_id', true ) );
$eb_url = esc_url( get_post_meta( $post->ID, '_eb4tec_eb_url', true ) );
$last_synced = (int) get_post_meta( $post->ID, '_eb4tec_last_synced', true );
$eb_status = esc_html( get_post_meta( $post->ID, '_eb4tec_eb_status', true ) );
$push_on_save = (bool) get_post_meta( $post->ID, '_eb4tec_push_on_save', true );
$exclude = (bool) get_post_meta( $post->ID, '_eb4tec_exclude_sync', true );
$capacity = (int) get_post_meta( $post->ID, '_eb4tec_capacity', true );
?>
<p>
<label for="eb4tec_event_id"><strong><?php esc_html_e( 'Eventbrite Event ID', 'eb4tec' ); ?></strong></label><br>
<input type="text" id="eb4tec_event_id" name="eb4tec_event_id"
value="<?php echo $eb_id; ?>" class="widefat" placeholder="e.g. 123456789">
</p>
<?php if ( $eb_url ) : ?>
<p><a href="<?php echo $eb_url; ?>" target="_blank" rel="noopener"><?php esc_html_e( 'View on Eventbrite', 'eb4tec' ); ?> ↗</a></p>
<?php endif; ?>
<?php if ( $last_synced ) : ?>
<p class="description">
<?php printf( esc_html__( 'Last synced: %s ago', 'eb4tec' ), esc_html( human_time_diff( $last_synced ) ) ); ?>
<?php if ( $eb_status ) : ?>
| <?php printf( esc_html__( 'EB status: %s', 'eb4tec' ), $eb_status ); ?>
<?php endif; ?>
</p>
<?php endif; ?>
<p>
<label for="eb4tec_capacity"><strong><?php esc_html_e( 'Ticket Capacity', 'eb4tec' ); ?></strong></label><br>
<input type="number" id="eb4tec_capacity" name="eb4tec_capacity"
value="<?php echo esc_attr( $capacity ); ?>" min="0" class="widefat">
<span class="description"><?php esc_html_e( 'Leave 0 for unlimited.', 'eb4tec' ); ?></span>
</p>
<p>
<label>
<input type="checkbox" name="eb4tec_push_on_save" value="1" <?php checked( $push_on_save ); ?>>
<?php esc_html_e( 'Push to Eventbrite when saved', 'eb4tec' ); ?>
</label>
</p>
<p>
<label>
<input type="checkbox" name="eb4tec_exclude_sync" value="1" <?php checked( $exclude ); ?>>
<?php esc_html_e( 'Exclude from automatic sync', 'eb4tec' ); ?>
</label>
</p>
<?php
}
public function on_save_post( int $post_id, WP_Post $post, bool $update ): void {
if ( ! wp_verify_nonce( $_POST['_eb4tec_event_meta_nonce'] ?? '', 'eb4tec_event_meta_save' ) ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Save meta box fields.
$eb_id = sanitize_text_field( $_POST['eb4tec_event_id'] ?? '' );
update_post_meta( $post_id, '_eb4tec_event_id', $eb_id );
$capacity = absint( $_POST['eb4tec_capacity'] ?? 0 );
update_post_meta( $post_id, '_eb4tec_capacity', $capacity );
$push = isset( $_POST['eb4tec_push_on_save'] );
$exclude = isset( $_POST['eb4tec_exclude_sync'] );
update_post_meta( $post_id, '_eb4tec_push_on_save', $push ? '1' : '' );
update_post_meta( $post_id, '_eb4tec_exclude_sync', $exclude ? '1' : '' );
// Push to Eventbrite now if requested.
if ( $push && ! $exclude ) {
// Avoid recursion: remove hook, push, re-add.
remove_action( 'save_post_tribe_events', [ $this, 'on_save_post' ], 20 );
$this->push_event( $post_id );
add_action( 'save_post_tribe_events', [ $this, 'on_save_post' ], 20, 3 );
}
}
// ---------------------------------------------------------------------------
// Helper: find TEC post by EB event ID
// ---------------------------------------------------------------------------
public function find_tec_event( string $eb_event_id ): int {
$query = new WP_Query( [
'post_type' => 'tribe_events',
'post_status' => [ 'publish', 'draft', 'private' ],
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [ [
'key' => '_eb4tec_event_id',
'value' => $eb_event_id,
] ],
] );
return $query->posts[0] ?? 0;
}
// ---------------------------------------------------------------------------
// Field mapping
// ---------------------------------------------------------------------------
private function eb_event_to_tec_args( array $eb_event ): array {
$status = $eb_event['status'] ?? 'draft';
$post_status = in_array( $status, [ 'live', 'started', 'ended', 'completed' ], true ) ? 'publish' : 'draft';
return [
'post_title' => sanitize_text_field( $eb_event['name']['text'] ?? __( 'Untitled Event', 'eb4tec' ) ),
'post_content' => wp_kses_post( $eb_event['description']['html'] ?? '' ),
'post_type' => 'tribe_events',
'post_status' => $post_status,
];
}
private function update_tec_from_eb( int $post_id, array $eb_event ): void {
update_post_meta( $post_id, '_eb4tec_event_id', sanitize_text_field( $eb_event['id'] ) );
update_post_meta( $post_id, '_eb4tec_eb_url', esc_url_raw( $eb_event['url'] ?? '' ) );
update_post_meta( $post_id, '_eb4tec_eb_status', sanitize_text_field( $eb_event['status'] ?? '' ) );
update_post_meta( $post_id, '_eb4tec_last_synced', time() );
// Dates — stored in local time in TEC.
$start_local = sanitize_text_field( $eb_event['start']['local'] ?? '' );
$end_local = sanitize_text_field( $eb_event['end']['local'] ?? '' );
$timezone = sanitize_text_field( $eb_event['start']['timezone'] ?? '' );
if ( $start_local ) {
update_post_meta( $post_id, '_EventStartDate', $start_local );
}
if ( $end_local ) {
update_post_meta( $post_id, '_EventEndDate', $end_local );
}
if ( $timezone ) {
update_post_meta( $post_id, '_EventTimezone', $timezone );
}
// Capacity.
$capacity = (int) ( $eb_event['capacity'] ?? 0 );
if ( $capacity > 0 ) {
update_post_meta( $post_id, '_eb4tec_capacity', $capacity );
update_post_meta( $post_id, '_EventCapacity', $capacity );
}
// Cost — try to get from ticket classes.
$ticket_classes = $eb_event['ticket_classes'] ?? [];
$min_cost = null;
foreach ( $ticket_classes as $tc ) {
if ( ! empty( $tc['free'] ) ) {
continue;
}
$cost = (float) ( $tc['cost']['major_value'] ?? 0 );
if ( null === $min_cost || $cost < $min_cost ) {
$min_cost = $cost;
}
}
if ( null !== $min_cost ) {
update_post_meta( $post_id, '_EventCost', (string) $min_cost );
update_post_meta( $post_id, '_EventCurrencySymbol', sanitize_text_field( $ticket_classes[0]['cost']['currency'] ?? 'GBP' ) );
}
// Featured image.
if ( ! has_post_thumbnail( $post_id ) && ! empty( $eb_event['logo']['url'] ) ) {
$this->sideload_image( $eb_event['logo']['url'], $post_id, get_the_title( $post_id ) );
}
}
private function tec_post_to_eb_body( int $post_id ): array {
$post = get_post( $post_id );
$start = (string) get_post_meta( $post_id, '_EventStartDate', true );
$end = (string) get_post_meta( $post_id, '_EventEndDate', true );
$timezone = (string) get_post_meta( $post_id, '_EventTimezone', true ) ?: wp_timezone_string();
$currency = (string) get_post_meta( $post_id, '_EventCurrencySymbol', true ) ?: 'GBP';
// Convert local dates to UTC for the EB API.
$tz_obj = new DateTimeZone( $timezone );
$start_dt = new DateTime( $start, $tz_obj );
$end_dt = new DateTime( $end, $tz_obj );
$start_dt->setTimezone( new DateTimeZone( 'UTC' ) );
$end_dt->setTimezone( new DateTimeZone( 'UTC' ) );
return [
'event' => [
'name' => [ 'html' => $post->post_title ],
'description' => [ 'html' => $post->post_content ],
'start' => [
'timezone' => $timezone,
'utc' => $start_dt->format( 'Y-m-d\TH:i:s\Z' ),
],
'end' => [
'timezone' => $timezone,
'utc' => $end_dt->format( 'Y-m-d\TH:i:s\Z' ),
],
'currency' => $currency,
'online_event' => false,
'listed' => true,
'shareable' => true,
],
];
}
private function sideload_image( string $url, int $post_id, string $title ): void {
if ( ! function_exists( 'media_sideload_image' ) ) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
$attachment_id = media_sideload_image( $url, $post_id, $title, 'id' );
if ( ! is_wp_error( $attachment_id ) ) {
set_post_thumbnail( $post_id, $attachment_id );
}
}
}