Al-HUWAITI Shell
Al-huwaiti


Server : Apache
System : Linux webd003.cluster111.gra.hosting.ovh.net 5.15.206-ovh-vps-grsec-zfs-classid #1 SMP Fri May 15 02:41:25 UTC 2026 x86_64
User : edevmultrx ( 811899)
PHP Version : 7.4.33
Disable Function : _dyuweyrj4,_dyuweyrj4r,dl
Directory :  /home/edevmultrx/www/terra-d-oro/api/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Current File : /home/edevmultrx/www/terra-d-oro/api/stripe-webhook.php
<?php
/**
 * Terra D'Oro — Stripe Webhook Handler  [Version Debug Totale]
 * Route: POST /api/stripe-webhook
 *
 * Toutes les étapes sont tracées dans logs/webhook_debug.log.
 * Comment lire ce fichier :
 *   — Via FTP : télécharger <racine>/logs/webhook_debug.log
 *   — Via terminal : tail -f logs/webhook_debug.log
 *   — Via l'admin (si une page de log est créée plus tard)
 *
 * Logique :
 *   1. Capture corps brut (avant tout)
 *   2. Initialise le log dédié (2ème action du script)
 *   3. Vérifie la signature Stripe avec diagnostics verbeux
 *   4. Met à jour la DB (status + paid_amount)
 *   5. Envoie les emails
 *   6. Buste le cache iCal
 *   7. Filet global try/catch — aucun crash silencieux
 */

declare(strict_types=1);

// ════════════════════════════════════════════════════════════════════════
// A. CAPTURE DU CORPS BRUT
//    DOIT être la toute première action — Stripe signe les octets exacts.
//    Ne jamais lire $_POST ni démarrer une session avant cette ligne.
// ════════════════════════════════════════════════════════════════════════

$_raw_body   = file_get_contents('php://input');
$_sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

// ════════════════════════════════════════════════════════════════════════
// B. LOG DE DEBUG DÉDIÉ  (2ème action du script)
//    Utilise dirname(__DIR__) car ROOT_PATH n'est pas encore défini.
//    Chaque étape importante écrit ici — lisible via FTP sans accès SSH.
// ════════════════════════════════════════════════════════════════════════

// $_wh_root est toujours calculé depuis __DIR__ (indépendant du scope)
// et sera réutilisé plus bas pour définir ROOT_PATH.
$_wh_root = dirname(__DIR__);

/**
 * Écrit une ligne horodatée dans logs/webhook_debug.log ET dans le log
 * PHP système (error_log).
 *
 * POURQUOI static et pas global ?
 * Ce script est chargé via require depuis ag_dispatch_api(), une fonction
 * PHP. Toutes les variables définies ici (ex: $log_file) sont donc dans
 * le scope LOCAL de cette fonction, pas dans le scope global ($GLOBALS).
 * En PHP 8+, `global $var` accède à $GLOBALS['var'] qui est null → TypeError
 * (le @ ne supprime pas TypeError en PHP 8). La solution : utiliser static,
 * qui persiste entre les appels sans dépendre du scope d'appel, et calculer
 * le chemin depuis __DIR__ qui est toujours absolu et correct.
 */
function wh_log(string $msg): void
{
    // static : initialisé une seule fois par requête, mémorisé entre appels
    static $log_path = null;

    if ($log_path === null) {
        // __DIR__ = répertoire de CE fichier (api/), dirname() remonte à la racine
        $log_dir  = dirname(__DIR__) . '/logs';

        // Créer logs/ si absent (0777 pour hébergements mutualisés)
        if (!is_dir($log_dir)) {
            @mkdir($log_dir, 0777, true);
        }

        $log_path = $log_dir . '/webhook_debug.log';

        // Rotation si > 2 Mo — évite de saturer le disque
        if (is_file($log_path) && filesize($log_path) > 2_097_152) {
            @rename($log_path, $log_path . '.old');
        }
    }

    $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n";
    // $log_path est toujours une string ici — pas de TypeError possible
    @file_put_contents($log_path, $line, FILE_APPEND | LOCK_EX);
    error_log('[TD-Webhook] ' . $msg);
}

// Première entrée — visible immédiatement dans le log
wh_log('');
wh_log('====================================================');
wh_log('DEMARRAGE WEBHOOK');
wh_log('  Methode      : ' . ($_SERVER['REQUEST_METHOD'] ?? '?'));
wh_log('  Corps (bytes): ' . strlen($_raw_body));
wh_log('  Signature    : ' . (empty($_sig_header) ? 'ABSENTE <-- probleme !' : 'presente (' . strlen($_sig_header) . ' chars)'));
wh_log('  PHP          : ' . PHP_VERSION . ' / ' . PHP_OS);
wh_log('  Heure serveur: ' . date('Y-m-d H:i:s') . ' (' . date_default_timezone_get() . ')');
wh_log('  Fichier      : ' . __FILE__);
wh_log('====================================================');

// ════════════════════════════════════════════════════════════════════════
// C. FONCTIONS UTILITAIRES
//    Définies avant le try/catch pour être disponibles dans le catch.
// ════════════════════════════════════════════════════════════════════════

/**
 * Retourne une réponse JSON et termine le script.
 * HTTP 200 = Stripe considère le webhook livré (pas de retry).
 * HTTP 4xx = Stripe retentera avec back-off exponentiel.
 */
function webhook_respond(int $code, string $message): never
{
    wh_log('-> Reponse HTTP ' . $code . ': ' . $message);
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store');
    echo json_encode(['message' => $message]);
    exit;
}

// ════════════════════════════════════════════════════════════════════════
// D. FILET DE SECURITE GLOBAL
//    Toute exception non rattrapée atterrit ici — jamais d'écran blanc.
// ════════════════════════════════════════════════════════════════════════

try {

// ── Bootstrap ─────────────────────────────────────────────────────────────────

if (!defined('ROOT_PATH')) {
    define('ROOT_PATH', $_wh_root);
}
wh_log('  ROOT_PATH : ' . ROOT_PATH);

require_once ROOT_PATH . '/core/config.php';
wh_log('  [OK] core/config.php');

require_once ROOT_PATH . '/core/db.php';
wh_log('  [OK] core/db.php  -- MySQL: ' . DB_NAME . '@' . DB_HOST);

require_once ROOT_PATH . '/core/helpers.php';
wh_log('  [OK] core/helpers.php');

require_once ROOT_PATH . '/core/payment.php';
wh_log('  [OK] core/payment.php');
// NOTE: ICalParser et SyncManager sont chargés conditionnellement plus bas
// pour qu'un fichier manquant ne bloque pas le traitement du paiement.

// Crée logs/emails/ proactivement
$_email_log_dir = ROOT_PATH . '/logs/emails';
if (!is_dir($_email_log_dir)) {
    if (mkdir($_email_log_dir, 0777, true)) {
        wh_log('  [OK] logs/emails/ cree');
    } else {
        wh_log('  [!!] logs/emails/ ECHEC creation -- verifier les droits sur ' . ROOT_PATH . '/logs/');
    }
} else {
    wh_log('  [OK] logs/emails/ existe deja');
}
unset($_email_log_dir);

// ── Garde méthode HTTP ────────────────────────────────────────────────────────

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    wh_log('[!!] Methode refusee: ' . ($_SERVER['REQUEST_METHOD'] ?? '?'));
    webhook_respond(405, 'Method Not Allowed');
}

// ════════════════════════════════════════════════════════════════════════
// E. VERIFICATION DE LA SIGNATURE STRIPE (avec diagnostics complets)
//
// Causes communes d'echec :
//   1. STRIPE_WEBHOOK_SECRET incorrect (whsec_ change a chaque restart CLI)
//   2. Evenement trop vieux (>5 min) -- utiliser "stripe listen" en dev,
//      pas "stripe events resend" qui rejoue un ancien timestamp
//   3. Corps modifie par un proxy, cache, ou middleware gzip
// ════════════════════════════════════════════════════════════════════════

wh_log('----------------------------------------------------');
wh_log('VERIFICATION SIGNATURE');
wh_log('  Secret (debut): ' . substr(STRIPE_WEBHOOK_SECRET, 0, 16) . '...');

if (empty($_sig_header)) {
    wh_log('[ECHEC] Header Stripe-Signature ABSENT.');
    wh_log('  -> Si Stripe CLI: stripe listen --forward-to ' . BASE_URL . '/api/stripe-webhook');
    wh_log('  -> Si Dashboard Stripe: verifier que l\'endpoint pointe bien vers ce fichier');
    webhook_respond(400, 'Missing Stripe-Signature header');
}

// Extraire le timestamp depuis l'en-tête pour diagnostic précis
// Format: t=1234567890,v1=abc123...
$_ts_value = 0;
foreach (explode(',', $_sig_header) as $_sig_part) {
    if (str_starts_with(trim($_sig_part), 't=')) {
        $_ts_value = (int) substr(trim($_sig_part), 2);
        break;
    }
}
$_ts_age = abs(time() - $_ts_value);

wh_log('  Timestamp dans sig : ' . $_ts_value
     . ' (' . ($_ts_value > 0 ? date('Y-m-d H:i:s', $_ts_value) : 'INVALIDE') . ')');
wh_log('  Horloge PHP        : ' . time() . ' (' . date('Y-m-d H:i:s') . ')');
wh_log('  Age de l\'evenement : ' . $_ts_age . 's'
     . ($_ts_age > 300 ? ' <-- TROP VIEUX (>300s) : verification va ECHOUER' : ' (OK, < 5 min)'));
wh_log('  Sig header debut   : ' . substr($_sig_header, 0, 70) . '...');
unset($_ts_value, $_sig_part);

// Appel de la verification
$_event = verify_stripe_webhook($_raw_body, $_sig_header);

if ($_event === false) {
    wh_log('');
    wh_log('[ECHEC] SIGNATURE STRIPE INVALIDE');
    wh_log('  Diagnostics:');
    wh_log('  1. STRIPE_WEBHOOK_SECRET incorrect ?');
    wh_log('     Valeur actuelle (16 premiers chars): ' . substr(STRIPE_WEBHOOK_SECRET, 0, 16) . '...');
    wh_log('     -- Stripe CLI regenere ce secret a chaque "stripe listen".');
    wh_log('     -- Solution: copier le nouveau "whsec_..." du terminal CLI dans config.php,');
    wh_log('       ligne: define(\'STRIPE_WEBHOOK_SECRET\', ...)');
    wh_log('     -- Dashboard: Developpeurs -> Webhooks -> ton endpoint -> Signing secret');
    wh_log('  2. Evenement trop vieux (age=' . $_ts_age . 's > 300s) ?');
    wh_log('     -- Utilise "stripe listen" (events frais), pas "stripe events resend"');
    wh_log('  3. Corps modifie par proxy/middleware ?');
    wh_log('     -- Taille corps recue: ' . strlen($_raw_body) . ' bytes');
    wh_log('     -- Si 0 bytes: le corps est vide -> php://input non disponible');
    webhook_respond(400, 'Webhook signature verification failed');
}

// ── Signature valide ──────────────────────────────────────────────────────────

$_event_type = $_event['type'] ?? '';
wh_log('[OK] Signature valide -- event_type: ' . $_event_type);

if ($_event_type !== 'checkout.session.completed') {
    wh_log('-> Evenement non pertinent, ignore (retour 200 pour eviter les retries Stripe)');
    webhook_respond(200, 'Received');
}

// ════════════════════════════════════════════════════════════════════════
// F. TRAITEMENT DE checkout.session.completed
// ════════════════════════════════════════════════════════════════════════

$session    = $_event['data']['object'] ?? [];
$booking_id = (int)    ($session['metadata']['booking_id'] ?? 0);
$session_id = (string) ($session['id']                     ?? '');

wh_log('----------------------------------------------------');
wh_log('checkout.session.completed');
wh_log('  booking_id     : ' . ($booking_id > 0 ? $booking_id : 'MANQUANT <--'));
wh_log('  session_id     : ' . ($session_id !== '' ? $session_id : 'MANQUANT <--'));
wh_log('  payment_status : ' . ($session['payment_status'] ?? '?'));
wh_log('  amount_total   : ' . ($session['amount_total']   ?? '?') . ' centimes');
wh_log('  metadata Stripe: ' . json_encode($session['metadata'] ?? []));

if ($booking_id <= 0 || $session_id === '') {
    wh_log('[ECHEC] booking_id ou session_id absent dans les metadata Stripe.');
    wh_log('  -> Verifier que create_stripe_session() passe bien metadata[booking_id].');
    webhook_respond(400, 'Missing booking_id in Stripe metadata');
}

// ── Lecture base de données ────────────────────────────────────────────────────

wh_log('----------------------------------------------------');
wh_log('LECTURE DB -- booking #' . $booking_id);

$stmt = $db->prepare("
    SELECT b.id, b.unit_id, b.guest_name, b.guest_email,
           b.check_in, b.check_out, b.nights, b.total_price, b.paid_amount,
           u.slug, u.name AS unit_name
    FROM   `" . TBL_BOOKINGS . "` b
    JOIN   `" . TBL_UNITS . "`    u ON u.id = b.unit_id
    WHERE  b.id     = :id
      AND  b.status = 'awaiting_payment'
    LIMIT  1
");
$stmt->execute([':id' => $booking_id]);
$booking = $stmt->fetch();

if (!$booking) {
    // Diagnostic : vérifier le statut actuel pour expliquer pourquoi c'est vide
    $chk_stmt = $db->prepare("SELECT id, status, paid_amount FROM `" . TBL_BOOKINGS . "` WHERE id = :id LIMIT 1");
    $chk_stmt->execute([':id' => $booking_id]);
    $existing = $chk_stmt->fetch();

    if ($existing) {
        wh_log('[INFO] Reservation #' . $booking_id . ' trouvee mais statut actuel = "'
             . $existing['status'] . '", paid_amount = ' . $existing['paid_amount']
             . ' -> probablement deja traitee (retry Stripe ou traitement concurrent).');
    } else {
        wh_log('[ECHEC] Reservation #' . $booking_id . ' INTROUVABLE dans bookings.');
        wh_log('  -> Le booking_id dans les metadata Stripe ne correspond a aucune ligne.');
        wh_log('  -> Verifier que la reservation a bien ete creee avant la session Stripe.');
    }
    // Retourner 200 pour stopper les retries Stripe
    webhook_respond(200, 'Booking not found or already processed');
}

wh_log('[OK] Reservation trouvee:');
wh_log('  guest      : ' . $booking['guest_name'] . ' <' . $booking['guest_email'] . '>');
wh_log('  logement   : ' . $booking['unit_name'] . ' (slug: ' . $booking['slug'] . ')');
wh_log('  dates      : ' . $booking['check_in'] . ' -> ' . $booking['check_out']);
wh_log('  nuits      : ' . $booking['nights']);
wh_log('  total DB   : ' . $booking['total_price'] . ' EUR');

// ── Calcul du statut final ─────────────────────────────────────────────────────

$stripe_paid_eur = (float) ($session['amount_total'] ?? 0) / 100;
$total_booking   = (float) $booking['total_price'];
$threshold       = $total_booking - 0.50;   // tolérance arrondi Stripe
$is_fully_paid   = $stripe_paid_eur >= $threshold;
$new_status      = $is_fully_paid ? 'confirmed' : 'deposit_paid';

wh_log('----------------------------------------------------');
wh_log('CALCUL STATUT');
wh_log('  Stripe encaisse  : ' . $stripe_paid_eur . ' EUR');
wh_log('  Total reservation: ' . $total_booking . ' EUR');
wh_log('  Seuil pmt integral: ' . $threshold . ' EUR (total - 0.50 tolerance)');
wh_log('  Decision         : '
     . ($is_fully_paid
         ? 'PAIEMENT INTEGRAL -> status = confirmed'
         : 'ACOMPTE -> status = deposit_paid (solde du a l\'arrivee)'));

// ── UPDATE base de données ─────────────────────────────────────────────────────

wh_log('----------------------------------------------------');
wh_log('UPDATE bookings SET status = ' . $new_status . ', paid_amount = ' . $stripe_paid_eur);

$upd = $db->prepare("
    UPDATE `" . TBL_BOOKINGS . "`
    SET    status            = :status,
           stripe_session_id = :sid,
           paid_amount       = :paid
    WHERE  id                = :id
      AND  status            = 'awaiting_payment'
");
$upd->execute([
    ':status' => $new_status,
    ':sid'    => $session_id,
    ':paid'   => $stripe_paid_eur,
    ':id'     => $booking_id,
]);

$rows_affected = $upd->rowCount();
wh_log('  rowCount() = ' . $rows_affected);

if ($rows_affected === 0) {
    // La ligne n'était plus en 'awaiting_payment' — déjà traitée
    wh_log('[INFO] 0 ligne mise a jour -- traitement concurrent detecte. Retour 200 sans email.');
    webhook_respond(200, 'Concurrent processing — already handled');
}

wh_log('[OK] DB mise a jour: status=' . $new_status . ' | paid_amount=' . $stripe_paid_eur . ' EUR');

// ════════════════════════════════════════════════════════════════════════
// G. EMAILS DE CONFIRMATION
//    Envoyés APRES la mise à jour DB — jamais avant.
//    Enveloppés dans try/catch pour qu'une erreur email ne bloque pas le 200.
// ════════════════════════════════════════════════════════════════════════

wh_log('----------------------------------------------------');
wh_log('EMAILS');

$email_params = [
    'guest_name'  => $booking['guest_name'],
    'guest_email' => $booking['guest_email'],
    'unit_name'   => $booking['unit_name'],
    'check_in'    => $booking['check_in'],
    'check_out'   => $booking['check_out'],
    'nights'      => (int)   $booking['nights'],
    'total'       => (float) $booking['total_price'],
    'deposit'     => $stripe_paid_eur,
    'balance'     => round($total_booking - $stripe_paid_eur, 2),
    'booking_id'  => $booking_id,
];

try {
    wh_log('  -> Client: ' . $booking['guest_email']);
    $ok_guest = send_mail(
        $booking['guest_email'],
        'Confirmation de votre réservation — ' . SITE_NAME,
        build_email_html($email_params, false)
    );
    wh_log('  Email client: ' . ($ok_guest ? '[OK] voir logs/emails/' : '[ECHEC] send_mail() a echoue'));

    $admin_subj = ($is_fully_paid ? 'Paiement integral' : 'Acompte recu')
                . ' : ' . $booking['unit_name'] . ' #' . $booking_id;
    wh_log('  -> Admin: ' . CONTACT_EMAIL);
    $ok_admin = send_mail(CONTACT_EMAIL, $admin_subj, build_email_html($email_params, true));
    wh_log('  Email admin : ' . ($ok_admin ? '[OK] voir logs/emails/' : '[ECHEC] send_mail() a echoue'));

} catch (\Throwable $email_ex) {
    wh_log('[EXCEPTION EMAIL] ' . $email_ex->getMessage());
    wh_log('  Fichier: ' . $email_ex->getFile() . ':' . $email_ex->getLine());
    wh_log('  -> La DB est deja mise a jour, la reservation est valide.');
}

// ════════════════════════════════════════════════════════════════════════
// H. ICAL — CACHE BUST + SYNC OTA (non bloquant)
// ════════════════════════════════════════════════════════════════════════

wh_log('----------------------------------------------------');
wh_log('ICAL CACHE BUST');
$ics_path = ICAL_FEEDS_PATH . '/' . $booking['slug'] . '.ics';
$busted   = @unlink($ics_path);
wh_log('  ' . $ics_path . ' : ' . ($busted ? '[OK] supprime' : '(absent ou non supprime)'));

// Sync OTA non-critique — chargement conditionnel pour éviter tout crash fatal
try {
    $ical_parser_file  = ROOT_PATH . '/core/ical/ICalParser.php';
    $sync_manager_file = ROOT_PATH . '/core/ical/SyncManager.php';

    if (is_file($ical_parser_file) && is_file($sync_manager_file)) {
        require_once $ical_parser_file;
        require_once $sync_manager_file;
        (new SyncManager($db, 10))->setForce(true)->syncUnit((int) $booking['unit_id']);
        wh_log('OTA SYNC: [OK] declenche pour unit_id=' . $booking['unit_id']);
    } else {
        wh_log('OTA SYNC: ignore (ICalParser.php ou SyncManager.php absent)');
    }
} catch (\Throwable $sync_ex) {
    wh_log('OTA SYNC: [ECHEC non bloquant] ' . $sync_ex->getMessage());
}

wh_log('====================================================');
wh_log('TERMINE -- booking_id=' . $booking_id . ' | status=' . $new_status);
wh_log('====================================================');

// ════════════════════════════════════════════════════════════════════════
// FERMETURE DU TRY — Filet de sécurité global
// ════════════════════════════════════════════════════════════════════════
} catch (\Throwable $global_ex) {
    $err = 'EXCEPTION GLOBALE: ' . $global_ex->getMessage()
         . ' in ' . $global_ex->getFile() . ':' . $global_ex->getLine();

    wh_log('');
    wh_log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
    wh_log('CRASH TOTAL DU WEBHOOK');
    wh_log($err);
    wh_log('Trace: ' . str_replace("\n", ' | ', $global_ex->getTraceAsString()));
    wh_log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');

    if (!headers_sent()) {
        http_response_code(500);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(['error' => 'Internal error — check logs/webhook_debug.log']);
    }
    exit;
}

// Tous les autres types d'événements : on répond 200 pour éviter les retries Stripe
webhook_respond(200, 'Received');

Al-HUWAITI Shell