◆ BookingManager v2

Sincronizzazione iCal con Airbnb e Booking: problemi e soluzioni

Come ho implementato la sincronizzazione bidirezionale iCal tra un booking engine PHP custom e i portali OTA, i bug incontrati e le soluzioni adottate.

Fabio De Giovine · 9 min lettura ·
PHP 8.2 MySQL iCal/ICS Stripe API PayPal API v2 PHPMailer SMTP Aruba
▶ Demo live
37
File PHP
v2
Versione
Proprietà
0%
Commissioni

Il problema: prenotazioni doppie tra Airbnb e sito diretto

Il cliente ha un B&B su Airbnb e Booking.com, ma vuole anche prenotazioni dirette dal suo sito — senza commissioni OTA (che arrivano al 15-18%). Il problema classico: se qualcuno prenota su Airbnb, la stessa data deve risultare occupata sul sito, e viceversa. In tempo reale.

La soluzione standard è iCal — un formato di calendario (.ics) che tutti i portali supportano per l'import/export delle disponibilità. Suona semplice. Non lo è.

Come funziona iCal in pratica

Il protocollo è pull-based: ogni portale scarica periodicamente un URL .ics dal tuo server e aggiorna le sue disponibilità. Non è un webhook. Non è real-time. Airbnb fa il pull ogni 3 ore, Booking.com ogni 2-4 ore.

// Generazione feed iCal per una proprietà
function generateICalFeed($propertyId) {
    global $conn;
    
    $stmt = $conn->prepare("SELECT * FROM bookings 
        WHERE property_id = ? AND status IN ('confirmed','checked_in') 
        AND check_out >= CURDATE()");
    $stmt->bind_param("i", $propertyId);
    $stmt->execute();
    $bookings = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
    
    $ical  = "BEGIN:VCALENDAR\r\n";
    $ical .= "VERSION:2.0\r\n";
    $ical .= "PRODID:-//BookingManager//IT\r\n";
    $ical .= "CALSCALE:GREGORIAN\r\n";
    $ical .= "METHOD:PUBLISH\r\n";
    
    foreach ($bookings as $b) {
        $uid = 'bm-' . $b['id'] . '@' . $_SERVER['HTTP_HOST'];
        $ical .= "BEGIN:VEVENT\r\n";
        $ical .= "UID:" . $uid . "\r\n";
        $ical .= "DTSTART;VALUE=DATE:" . str_replace('-', '', $b['check_in']) . "\r\n";
        $ical .= "DTEND;VALUE=DATE:" . str_replace('-', '', $b['check_out']) . "\r\n";
        $ical .= "SUMMARY:Prenotato - " . htmlspecialchars($b['guest_name']) . "\r\n";
        $ical .= "STATUS:CONFIRMED\r\n";
        $ical .= "END:VEVENT\r\n";
    }
    
    $ical .= "END:VCALENDAR\r\n";
    return $ical;
}

Il parsing dei feed esterni: dove iniziano i problemi

Importare i feed iCal da Airbnb e Booking sembra banale — è un file di testo strutturato. Ma ogni portale ha le sue "personalità":

  • Airbnb usa DTSTART;VALUE=DATE:20260415 (solo data, senza orario). Le prenotazioni includono anche i "blocked dates" che il proprietario ha impostato manualmente.
  • Booking.com a volte include l'orario: DTSTART:20260415T150000Z. E il SUMMARY contiene il nome dell'ospite in formati diversi a seconda della lingua del portale.
  • Entrambi possono avere eventi sovrapposti che non corrispondono a prenotazioni reali ma a "buffer days" o blocchi manuali.
// Parser iCal robusto: gestisce formati data diversi
function parseICalDate($dateStr) {
    $dateStr = trim($dateStr);
    
    // Formato 1: 20260415 (solo data)
    if (preg_match('/^(\d{8})$/', $dateStr, $m)) {
        return substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
    }
    
    // Formato 2: 20260415T150000Z (con orario UTC)
    if (preg_match('/^(\d{8})T/', $dateStr, $m)) {
        return substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
    }
    
    // Fallback: prova DateTime
    try {
        $dt = new DateTime($dateStr);
        return $dt->format('Y-m-d');
    } catch (Exception $e) {
        return null; // Data non parsabile — logga e ignora
    }
}
Lezione appresa

Mai fidarsi del formato di un feed esterno. Ogni portale implementa iCal "a modo suo". Il parser deve essere difensivo: gestire tutti i formati noti, loggare quelli sconosciuti, e non crashare mai.

Il cron job che non c'è: sync su Aruba senza crontab

Su Aruba shared hosting non hai accesso a crontab. La sincronizzazione periodica dei feed esterni è gestita con un pseudo-cron PHP: ogni volta che qualcuno visita il sito, il sistema controlla se sono passate più di 2 ore dall'ultimo sync. Se sì, scarica i feed in background.

// Pseudo-cron: sync iCal al primo visitatore dopo 2 ore
function maybeSyncFeeds($propertyId) {
    $lockFile = __DIR__ . '/tmp/sync_' . $propertyId . '.lock';
    $lastSync = file_exists($lockFile) ? filemtime($lockFile) : 0;
    
    if (time() - $lastSync < 7200) return; // meno di 2 ore: skip
    
    touch($lockFile); // lock immediato per evitare sync paralleli
    
    // Scarica e parsa i feed in background
    $feeds = getFeedUrls($propertyId); // Airbnb URL, Booking URL
    foreach ($feeds as $feed) {
        $ics = @file_get_contents($feed['url']);
        if ($ics) {
            $events = parseICal($ics);
            syncEventsToDb($propertyId, $events, $feed['source']);
        }
    }
}
Workaround Aruba

Su hosting condivisi senza SSH, file_get_contents() per URL esterni è spesso disabilitato. Il fallback è cURL — che su Aruba funziona sempre. Tutto il networking del progetto passa da cURL.

Da v1 a v2: il refactor completo

HotelManager v1 usava SQLite e gestiva una singola proprietà. BookingManager v2 è un rewrite completo: MySQL, multi-proprietà, Stripe+Klarna, PayPal Checkout API v2, e un sistema di report fiscali mensili. Il refactor ha toccato 37 file — praticamente tutto.

La lezione più grande del refactor: non fare refactor incrementali su codice monolitico. È più veloce e pulito riscrivere da zero con l'architettura giusta, riutilizzando solo le query SQL e la logica business testata.