'POST', 'callback' => [self::class, 'handle_webhook'], 'permission_callback' => [self::class, 'permission_check'], 'args' => [ 'event' => [ 'required' => true, 'type' => 'string', 'validate_callback' => [self::class, 'validate_event'], ], ], ] ); } /** * Vérifie les permissions pour accéder à l'endpoint webhook. * * Les webhooks sont authentifiés via les headers personnalisés * X-ESIPeppol-Event, X-ESIPeppol-Document-ID, X-ESIPeppol-Company-ID. * * @param WP_REST_Request $request Requête REST. * @return bool|WP_Error */ public static function permission_check(WP_REST_Request $request) { // Vérifier la présence des headers requis $event_header = $request->get_header('X-ESIPeppol-Event'); $document_id_header = $request->get_header('X-ESIPeppol-Document-ID'); $company_id_header = $request->get_header('X-ESIPeppol-Company-ID'); if (empty($event_header) || empty($document_id_header) || empty($company_id_header)) { return new WP_Error( 'missing_headers', __('Headers webhook manquants.', 'esi_peppol'), ['status' => 400] ); } // Optionnel : vérifier que le Company-ID correspond à celui configuré // Pour l'instant, on accepte tous les webhooks avec les headers requis return true; } /** * Valide le type d'événement webhook. * * @param string $value Valeur à valider. * @param WP_REST_Request $request Requête REST. * @param string $param Nom du paramètre. * @return bool|WP_Error */ public static function validate_event($value, WP_REST_Request $request, string $param) { $allowed_events = ['document.sent', 'document.error', 'document.completed']; if (!in_array($value, $allowed_events, true)) { return new WP_Error( 'invalid_event', sprintf( /* translators: %s: liste des événements autorisés */ __('Type d\'événement invalide. Événements autorisés : %s', 'esi_peppol'), implode(', ', $allowed_events) ), ['status' => 400] ); } return true; } /** * Traite la réception d'un webhook. * * @param WP_REST_Request $request Requête REST contenant les données du webhook. * @return WP_REST_Response|WP_Error */ public static function handle_webhook(WP_REST_Request $request) { // Récupérer les headers $event = $request->get_header('X-ESIPeppol-Event'); $document_id = $request->get_header('X-ESIPeppol-Document-ID'); $company_id = $request->get_header('X-ESIPeppol-Company-ID'); // Récupérer le body JSON $body = $request->get_json_params(); if (empty($body)) { return new WP_Error( 'invalid_body', __('Corps de la requête invalide ou vide.', 'esi_peppol'), ['status' => 400] ); } // Valider la structure du payload if (!isset($body['event']) || !isset($body['document'])) { return new WP_Error( 'invalid_payload', __('Structure du payload invalide. Champs requis : event, document.', 'esi_peppol'), ['status' => 400] ); } // Vérifier la cohérence entre le header et le body if ($body['event'] !== $event) { return new WP_Error( 'event_mismatch', __('Le type d\'événement dans le header ne correspond pas à celui du body.', 'esi_peppol'), ['status' => 400] ); } // Extraire les données du document $document_data = $body['document']; // Le document_id peut être dans document.document_id (UUID) ou document.id (entier) // On privilégie document_id (UUID) car c'est celui stocké dans notre base $document_id_from_body = ''; if (isset($document_data['document_id']) && !empty($document_data['document_id'])) { $document_id_from_body = (string) $document_data['document_id']; } elseif (isset($document_data['id'])) { // Fallback sur l'ID numérique si document_id n'est pas disponible $document_id_from_body = (string) $document_data['id']; } // Utiliser le document_id du body si disponible, sinon celui du header $final_document_id = !empty($document_id_from_body) ? $document_id_from_body : $document_id; // Log de réception du webhook if (class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::write_debug_file( [ 'event' => 'webhook_received', 'event_type' => $event, 'document_id' => $final_document_id, 'company_id' => $company_id, 'payload' => $body, ], 'INFO' ); } // Traiter selon le type d'événement $result = self::process_webhook_event($event, $final_document_id, $body); if (is_wp_error($result)) { // Log de l'erreur if (class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::write_debug_file( [ 'event' => 'webhook_error', 'event_type' => $event, 'document_id' => $final_document_id, 'error' => $result->get_error_message(), 'error_code' => $result->get_error_code(), ], 'ERROR' ); } return $result; } // Réponse de succès (code 200-299 requis par la documentation) return new WP_REST_Response( [ 'success' => true, 'message' => __('Webhook traité avec succès.', 'esi_peppol'), ], 200 ); } /** * Traite un événement webhook spécifique. * * @param string $event Type d'événement (document.sent, document.error, document.completed). * @param string $document_id ID du document. * @param array $payload Données complètes du webhook. * @return bool|WP_Error */ private static function process_webhook_event(string $event, string $document_id, array $payload) { $document_data = $payload['document'] ?? []; // Déterminer le nouveau statut selon l'événement $new_status = null; if ($event === 'document.sent') { $new_status = 'sent'; } elseif ($event === 'document.error') { $new_status = 'error'; } elseif ($event === 'document.completed') { $new_status = 'completed'; } if ($new_status === null) { return new WP_Error( 'unknown_event', sprintf( /* translators: %s: type d'événement */ __('Type d\'événement inconnu : %s', 'esi_peppol'), $event ), ['status' => 400] ); } // Extraire le peppol_document_id si disponible $peppol_document_id = isset($document_data['peppol_document_id']) ? (string) $document_data['peppol_document_id'] : ''; // Extraire le message d'erreur si présent $message = ''; if ($event === 'document.error' && isset($payload['error']['message'])) { $message = (string) $payload['error']['message']; } // Mettre à jour le document dans la base de données $updated = PEPPOL_Main_model::update_by_document_id( $document_id, $new_status, $peppol_document_id, $message, $payload ); if (!$updated) { // Document non trouvé - ce n'est pas forcément une erreur critique // (le document pourrait avoir été créé manuellement dans l'interface ESI Peppol) if (class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::write_debug_file( [ 'event' => 'webhook_document_not_found', 'document_id' => $document_id, 'status' => $new_status, 'message' => __('Document non trouvé dans la base de données WordPress. Il a peut-être été créé manuellement dans l\'interface ESI Peppol.', 'esi_peppol'), ], 'WARNING' ); } // On retourne quand même true car le webhook a été reçu et traité // (même si le document n'existe pas dans notre base) return true; } // Log de succès if (class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::write_debug_file( [ 'event' => 'webhook_processed', 'event_type' => $event, 'document_id' => $document_id, 'new_status' => $new_status, 'peppol_document_id' => $peppol_document_id, ], 'INFO' ); } return true; } /** * Retourne l'URL complète de l'endpoint webhook. * * Cette URL doit être configurée dans l'interface d'administration Filament * de l'API ESI Peppol. * * @return string URL du webhook. */ public static function get_webhook_url(): string { $rest_url = rest_url('esi-peppol/v1/webhook'); /** * Filtre pour modifier l'URL du webhook. * * @param string $rest_url URL REST par défaut. */ return (string) apply_filters('esi_peppol_webhook_url', $rest_url); } }