EsiPeppol-Woocommerce/app/controllers/Webhook_controller.php
2025-12-16 16:30:44 +01:00

341 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace ESI_PEPPOL\controllers;
use ESI_PEPPOL\models\PEPPOL_Main_model;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
/**
* Contrôleur pour gérer la réception des webhooks depuis l'API ESI Peppol.
*
* Les webhooks permettent de recevoir des notifications automatiques
* lorsque le statut d'un document change.
*/
class PEPPOL_Webhook_controller {
/**
* Enregistre les routes REST API pour les webhooks.
*
* @return void
*/
public static function register_routes(): void {
// Route POST pour recevoir les webhooks
register_rest_route(
'esi-peppol/v1',
'/webhook',
[
'methods' => 'POST',
'callback' => [self::class, 'handle_webhook'],
'permission_callback' => [self::class, 'permission_check'],
'args' => [
'event' => [
'required' => true,
'type' => 'string',
'validate_callback' => [self::class, 'validate_event'],
],
],
]
);
// Route GET pour tester/vérifier l'endpoint
register_rest_route(
'esi-peppol/v1',
'/webhook',
[
'methods' => 'GET',
'callback' => [self::class, 'handle_webhook_get'],
'permission_callback' => '__return_true',
]
);
}
/**
* 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;
}
/**
* Gère les requêtes GET sur l'endpoint webhook.
* Utile pour tester/vérifier que l'endpoint est accessible.
*
* @param WP_REST_Request $request Requête REST.
* @return WP_REST_Response
*/
public static function handle_webhook_get(WP_REST_Request $request) {
return new WP_REST_Response(
[
'success' => true,
'message' => __('Endpoint webhook ESI Peppol actif.', 'esi_peppol'),
'endpoint' => 'esi-peppol/v1/webhook',
'methods' => ['GET', 'POST'],
'webhook_url' => self::get_webhook_url(),
],
200
);
}
/**
* 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);
}
}