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.
483 lines
16 KiB
PHP
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 );
|
|
}
|
|
}
|
|
}
|