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/ |
<?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');