logo oujood
🔍

Intégrer PayPal API v2 (Payments) avec PHP et MySQL - Guide complet 2025

Tutoriel pas-à-pas : configuration Developer, installation via Composer, structure du projet, .env, schéma MySQL, classe PayPalService, interface moderne, endpoints create/capture et webhooks sécurisés.

OUJOOD.COM

🚀 PayPal API v2 (Payments) avec PHP et MySQL : la solution moderne

La nouvelle API PayPal v2 (Payments) est plus moderne, sécurisée et flexible que l’ancienne. Elle permet une expérience fluide sans redirection complète vers PayPal, avec une interface mobile-first et des webhooks signés pour fiabiliser le suivi des paiements. Dans ce guide, on met en place une intégration propre et robuste avec PHP 8+, MySQL 8, le SDK officiel PayPal, le JavaScript SDK et des webhooks vérifiés.

✨ Avantages de l’API v2 :
  • 🎯 Paiement directement sur votre site (UX fluide)
  • 🔒 Sécurité renforcée (tokens, webhooks signés)
  • 📱 Design responsive et accessible
  • ⚡ Performances et simplicité d’intégration
  • 🌍 Multi-devises et international

📋 Prérequis

  • PHP 8.0+ (OK 7.4+, recommandé 8.1+)
  • Composer (gestion des dépendances)
  • MySQL 8.0+ ou MariaDB 10.4+
  • Extensions PHP : OpenSSL, cURL

🚀 Étape 1 : Créer un compte PayPal Developer

Avant de pouvoir accepter des paiements, vous devez passer par l’interface PayPal Developer. C’est une plateforme gratuite fournie par PayPal pour les développeurs. Elle permet de créer des identifiants, de tester des paiements sans argent réel (mode sandbox) et de configurer les webhooks.

  1. Rendez-vous sur https://developer.paypal.com/ et connectez-vous avec votre compte PayPal habituel.
    👉 Si vous n’avez pas encore de compte PayPal, créez-en un gratuitement.
  2. Accédez au menu “Apps & Credentials” puis cliquez sur Create App.
    Choisissez d’abord le mode Sandbox. Cela permet de simuler de vrais paiements sans utiliser votre carte bancaire.
    ➡️ Vous aurez automatiquement deux comptes de test (acheteur et marchand) pour vos essais.
  3. Une fois l’application créée, PayPal vous fournit plusieurs identifiants indispensables :
    • Client ID : identifiant public utilisé par votre site pour initialiser un paiement.
    • Client Secret : clé privée qui doit rester cachée côté serveur. Elle permet de sécuriser les échanges entre votre serveur et PayPal.
    • Webhook ID : identifiant du point de connexion que vous allez configurer pour recevoir automatiquement les notifications PayPal (paiement réussi, remboursement, etc.).

💡 Astuce : Gardez précieusement ces informations dans un fichier .env (nous verrons comment le créer à l’étape suivante). Ne copiez jamais votre Client Secret directement dans le code source, sinon il pourrait être exposé.

🔑 Définitions rapides

  • Sandbox : environnement de test PayPal où vous pouvez faire de “faux paiements” pour développer et vérifier votre intégration sans utiliser d’argent réel.
  • Application : c’est une configuration que vous créez dans PayPal Developer, qui regroupe vos identifiants (Client ID, Secret) et vos réglages (permissions, webhooks).
  • Client ID : identifiant public, utilisé dans le navigateur (JavaScript SDK) pour lancer une transaction.
  • Client Secret : mot de passe secret lié à votre Client ID, utilisé uniquement côté serveur pour sécuriser les échanges.
  • Webhook : URL de votre site appelée automatiquement par PayPal lorsqu’un événement survient (paiement confirmé, remboursement, etc.). Le Webhook ID est l’identifiant unique de ce lien.

📦 Étape 2 : Installer le SDK PayPal (Composer)

Maintenant que vous avez vos identifiants PayPal, il faut installer les bibliothèques nécessaires dans votre projet. Nous allons utiliser Composer, le gestionnaire de dépendances officiel pour PHP. Composer permet d’installer facilement des packages (bibliothèques externes) et de gérer automatiquement leurs mises à jour.

Ouvrez un terminal à la racine de votre projet, puis exécutez les commandes suivantes :

📋 Copier

composer require paypal/paypal-checkout-sdk
composer require vlucas/phpdotenv
composer require monolog/monolog

🛠️ Explications des packages installés :

  • paypal/paypal-checkout-sdk : le SDK officiel de PayPal pour PHP. C’est lui qui permet de créer des commandes (create-order), de capturer les paiements (capture-order) et de communiquer avec l’API PayPal.
  • vlucas/phpdotenv : une bibliothèque qui permet de stocker vos identifiants (Client ID, Client Secret, Webhook ID) dans un fichier .env. Cela améliore la sécurité en évitant de mettre des données sensibles directement dans votre code.
  • monolog/monolog : un outil de logging (journalisation). Il permet d’enregistrer dans des fichiers logs les erreurs, les transactions et les événements de votre application. Très utile pour le débogage et pour garder une trace des paiements.

💡 Astuce : Si vous n’avez pas encore installé Composer, téléchargez-le depuis getcomposer.org. Ensuite, dans votre terminal, tapez composer -V pour vérifier que l’installation fonctionne (cela doit afficher la version installée).

📚 Définitions utiles

  • SDK (Software Development Kit) : ensemble d’outils et de bibliothèques fournis par PayPal pour interagir facilement avec leur API.
  • Composer : gestionnaire de dépendances PHP. Il télécharge et installe automatiquement les bibliothèques dont votre projet a besoin.
  • Dépendance : une bibliothèque externe qu’un projet utilise. Exemple : ici, notre projet “dépend” du SDK PayPal.
  • Logging : action d’enregistrer des événements ou erreurs dans un fichier pour comprendre ce qu’il s’est passé (utile en cas de problème).

🏗️ Étape 3 : Structure recommandée du projet

Pour garder un projet clair, sécurisé et facile à maintenir, il est essentiel d’adopter une bonne organisation des dossiers. Voici une structure type adaptée à l’intégration de PayPal avec PHP et MySQL :

projet-paypal-v2/
├── vendor/
├── config/
│   ├── .env
│   └── database.php
├── src/
│   ├── PayPalService.php
│   └── WebhookHandler.php
├── public/
│   ├── index.php
│   ├── success.php
│   ├── cancel.php
│   └── webhook.php
├── api/
│   ├── create-order.php
│   └── capture-order.php
└── assets/
    ├── css/
    └── js/

🔎 Explications des dossiers et fichiers :

  • vendor/ : dossier créé automatiquement par Composer. Il contient toutes les bibliothèques installées (par exemple le SDK PayPal). 👉 Ne jamais modifier son contenu manuellement.
  • config/ :
    • .env : fichier qui stocke vos identifiants sensibles (Client ID, Secret, Webhook ID) et vos paramètres de base (DB, URL du site, mode sandbox/live).
    • database.php : script PHP qui gère la connexion sécurisée à MySQL.
  • src/ :
    • PayPalService.php : classe PHP qui encapsule toutes les fonctions pour créer, capturer et gérer les paiements.
    • WebhookHandler.php : classe PHP pour traiter automatiquement les notifications envoyées par PayPal (paiements validés, remboursements, etc.).
  • public/ : partie publique accessible depuis le navigateur.
    • index.php : page d’accueil ou page produit avec le bouton PayPal.
    • success.php : page affichée après un paiement réussi.
    • cancel.php : page affichée si l’utilisateur annule ou si le paiement échoue.
    • webhook.php : point d’entrée qui reçoit automatiquement les notifications PayPal.
  • api/ :
    • create-order.php : script qui crée une commande PayPal.
    • capture-order.php : script qui capture et confirme un paiement PayPal.
  • assets/ : ressources statiques (CSS, JS, images) utilisées par votre interface utilisateur.
    • css/ : feuilles de style.
    • js/ : scripts JavaScript personnalisés.

⚠️ Bonnes pratiques :

  • Ne mettez jamais vos fichiers sensibles (comme .env) dans public/, car ils doivent rester invisibles pour les visiteurs.
  • Gardez vos classes PHP dans src/ pour séparer la logique métier du code affiché au navigateur.
  • Utilisez api/ pour vos endpoints AJAX, ce qui permet d’avoir un code clair et organisé.

📚 Définitions utiles :

  • Endpoint : une URL qui exécute une action spécifique, par exemple /api/create-order.php pour créer une commande PayPal.
  • Webhook : un script de votre site appelé automatiquement par PayPal pour notifier un événement (paiement réussi, remboursement, etc.).
  • Public : tout fichier placé dans ce dossier est accessible directement depuis Internet (exemple : https://votresite.com/index.php).
  • src (source) : contient le code PHP principal de votre application, non accessible directement par les visiteurs.

⚙️ Étape 4 : Configuration du fichier .env

Le fichier .env (abréviation de “environment”) est un fichier texte qui contient vos paramètres de configuration sensibles et spécifiques à l’environnement (développement, test, production). 👉 Grâce à phpdotenv, votre code PHP peut facilement lire ces variables sans jamais les exposer dans le code source. C’est une bonne pratique de sécurité et cela permet de changer vos réglages sans modifier votre code.

📋 Copier

# Mode
PAYPAL_MODE=sandbox
# PAYPAL_MODE=live

# Sandbox
PAYPAL_CLIENT_ID_SANDBOX=VOTRE_CLIENT_ID_SANDBOX
PAYPAL_CLIENT_SECRET_SANDBOX=VOTRE_CLIENT_SECRET_SANDBOX

# Live (à remplir en prod)
PAYPAL_CLIENT_ID_LIVE=
PAYPAL_CLIENT_SECRET_LIVE=

# Webhook
PAYPAL_WEBHOOK_ID=VOTRE_WEBHOOK_ID

# Base de données
DB_HOST=localhost
DB_NAME=paypal_v2_db
DB_USERNAME=root
DB_PASSWORD=

# URLs
SITE_URL=http://localhost:8000

# Logs
LOG_LEVEL=debug

🔎 Explications des variables :

  • PAYPAL_MODE : définit si vous êtes en mode sandbox (test) ou live (production réelle).
  • PAYPAL_CLIENT_ID_SANDBOX et PAYPAL_CLIENT_SECRET_SANDBOX : vos identifiants PayPal pour l’environnement de test.
  • PAYPAL_CLIENT_ID_LIVE et PAYPAL_CLIENT_SECRET_LIVE : vos identifiants PayPal pour l’environnement de production.
  • PAYPAL_WEBHOOK_ID : identifiant unique du webhook configuré dans PayPal Developer, utilisé pour vérifier les notifications entrantes.
  • DB_HOST / DB_NAME / DB_USERNAME / DB_PASSWORD : paramètres de connexion à la base de données MySQL.
  • SITE_URL : l’URL publique de votre site. En local, cela peut être http://localhost:8000, et en production https://votredomaine.com.
  • LOG_LEVEL : définit le niveau de détails des logs. Exemple : debug (détails maximum), error (uniquement les erreurs).

💡 Conseils pratiques :

  • Ne partagez jamais votre fichier .env publiquement : il contient vos identifiants sensibles.
  • Ajoutez toujours .env dans votre fichier .gitignore pour éviter de l’envoyer sur GitHub ou un autre dépôt public.
  • Vous pouvez créer plusieurs fichiers selon vos besoins :
    • .env pour le développement local,
    • .env.production pour le serveur en ligne.

👉 Résumé : Le fichier .env est la “boîte à secrets” de votre projet. Il sépare la configuration (qui peut changer selon l’environnement) du code source (qui reste identique). C’est indispensable pour un projet professionnel et sécurisé.

💾 Étape 5 : Schéma MySQL (moderne)

Afin de gérer les produits, les commandes, les paiements et les webhooks, nous devons mettre en place une base de données MySQL bien structurée. Le schéma ci-dessous est pensé pour être moderne, extensible et sécurisé.

📋 Copier

CREATE DATABASE paypal_v2_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE paypal_v2_db;

CREATE TABLE produits (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nom VARCHAR(255) NOT NULL,
  description TEXT,
  prix DECIMAL(10,2) NOT NULL,
  devise VARCHAR(3) DEFAULT 'EUR',
  image_url VARCHAR(500),
  actif BOOLEAN DEFAULT TRUE,
  date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  date_modification TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_actif (actif),
  INDEX idx_prix (prix)
);

CREATE TABLE commandes (
  id INT AUTO_INCREMENT PRIMARY KEY,
  order_id VARCHAR(50) UNIQUE NOT NULL,
  produit_id INT,
  statut ENUM('CREATED','SAVED','APPROVED','VOIDED','COMPLETED','PAYER_ACTION_REQUIRED') DEFAULT 'CREATED',
  montant_total DECIMAL(10,2) NOT NULL,
  devise VARCHAR(3) NOT NULL,
  email_acheteur VARCHAR(255),
  nom_acheteur VARCHAR(255),
  adresse_ip VARCHAR(45),
  user_agent TEXT,
  date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  date_modification TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (produit_id) REFERENCES produits(id),
  INDEX idx_order_id (order_id),
  INDEX idx_statut (statut),
  INDEX idx_email (email_acheteur)
);

CREATE TABLE paiements (
  id INT AUTO_INCREMENT PRIMARY KEY,
  capture_id VARCHAR(50) UNIQUE NOT NULL,
  order_id VARCHAR(50) NOT NULL,
  commande_id INT,
  statut ENUM('COMPLETED','DECLINED','PARTIALLY_REFUNDED','REFUNDED','PENDING') DEFAULT 'PENDING',
  montant_capture DECIMAL(10,2) NOT NULL,
  frais_paypal DECIMAL(10,2) DEFAULT 0,
  devise VARCHAR(3) NOT NULL,
  date_capture TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  donnees_paypal JSON,
  FOREIGN KEY (commande_id) REFERENCES commandes(id),
  INDEX idx_capture_id (capture_id),
  INDEX idx_order_id (order_id),
  INDEX idx_statut (statut)
);

CREATE TABLE webhooks_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  webhook_id VARCHAR(100),
  event_type VARCHAR(100) NOT NULL,
  resource_type VARCHAR(50),
  resource_id VARCHAR(100),
  donnees_webhook JSON,
  signature_valide BOOLEAN DEFAULT FALSE,
  traite BOOLEAN DEFAULT FALSE,
  date_reception TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  date_traitement TIMESTAMP NULL,
  INDEX idx_event_type (event_type),
  INDEX idx_resource_id (resource_id),
  INDEX idx_traite (traite)
);

CREATE TABLE error_logs (
  id INT AUTO_INCREMENT PRIMARY KEY,
  niveau VARCHAR(20) DEFAULT 'ERROR',
  message TEXT NOT NULL,
  contexte JSON,
  fichier VARCHAR(255),
  ligne INT,
  date_erreur TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_niveau (niveau),
  INDEX idx_date (date_erreur)
);

INSERT INTO produits (nom, description, prix, devise, image_url) VALUES
('Formation PHP Avancée','Formation PHP/MySQL + PayPal v2',99.99,'EUR','https://via.placeholder.com/300x200'),
('E-book JavaScript','Guide moderne ES2023',29.99,'EUR','https://via.placeholder.com/300x200'),
('Plugin WordPress','Plugin e-commerce premium',49.99,'EUR','https://via.placeholder.com/300x200');

🔎 Explications des tables :

  • produits : contient la liste des articles vendus (formations, ebooks, plugins, etc.).
    • prix : stocké en DECIMAL(10,2) pour éviter les erreurs d’arrondi liées aux flottants.
    • actif : permet de désactiver un produit sans le supprimer.
  • commandes : enregistre les commandes PayPal créées.
    • order_id : identifiant unique donné par PayPal.
    • statut : indique l’état de la commande (CREATED, APPROVED, COMPLETED, etc.).
    • email_acheteur et nom_acheteur : permettent de lier la commande à un client.
    • adresse_ip et user_agent : informations utiles en cas de fraude ou litige.
  • paiements : stocke les paiements réellement capturés.
    • capture_id : identifiant unique de la transaction PayPal.
    • statut : ex. COMPLETED, REFUNDED, PENDING.
    • frais_paypal : utile si vous voulez calculer la marge nette après frais PayPal.
    • donnees_paypal : JSON contenant la réponse brute de PayPal (très pratique pour le debug).
  • webhooks_log : journalise les événements envoyés automatiquement par PayPal.
    • event_type : type d’événement (PAYMENT.CAPTURE.COMPLETED, PAYMENT.REFUNDED, etc.).
    • signature_valide : permet de vérifier si le webhook provient bien de PayPal.
    • traite : indique si votre script a déjà traité l’événement.
  • error_logs : enregistre les erreurs critiques pour le debug.
    • message : texte de l’erreur.
    • contexte : détails supplémentaires en JSON.
    • fichier et ligne : aident à retrouver rapidement l’origine de l’erreur.

📚 Définitions utiles :

  • PRIMARY KEY : identifiant unique de chaque ligne (ex. id).
  • FOREIGN KEY : clé étrangère qui relie une table à une autre (ex. une commande liée à un produit).
  • INDEX : améliore la rapidité des recherches sur une colonne (utile sur order_id, email_acheteur).
  • ENUM : type de champ SQL qui ne peut contenir qu’une valeur parmi une liste (par ex. statut d’une commande).
  • JSON : format qui permet de stocker des données complexes de manière flexible (réponses PayPal brutes).

👉 Résumé : Ce schéma MySQL vous donne une base solide pour gérer un site e-commerce avec PayPal v2. Il sépare bien les produits, les commandes, les paiements, les webhooks et les erreurs, ce qui facilite la maintenance et le suivi des transactions.

🧩 Fichier config/database.php

Ce fichier centralise la connexion sécurisée à MySQL via PDO et lit les variables depuis le fichier .env grâce à phpdotenv. Objectif : éviter de mettre en dur vos identifiants dans le code et rendre la configuration portable (local ↔️ production).

📋 Copier

<?php
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');
define('DB_NAME', $_ENV['DB_NAME'] ?? 'paypal_v2_db');
define('DB_USERNAME', $_ENV['DB_USERNAME'] ?? 'root');
define('DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? '');

function get_pdo(): PDO {
  $dsn = "mysql:host=".DB_HOST.";dbname=".DB_NAME.";charset=utf8mb4";
  $opt = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC ];
  return new PDO($dsn, DB_USERNAME, DB_PASSWORD, $opt);
}
🔎 Comment ça fonctionne (ligne par ligne)
  • Dotenv::createImmutable(__DIR__) : charge les variables du fichier .env situé dans le même dossier que database.php (donc /config/.env dans ta structure). Si ton .env est à la racine, utilise createImmutable(__DIR__.'/..').
  • $dotenv->load() : rend les clés du .env accessibles via $_ENV.
  • define(...) : crée des constantes PHP avec valeurs de repli (fallback) si une variable est absente du .env.
  • get_pdo() : fabrique et retourne une instance PDO configurée :
    • charset=utf8mb4 : support complet des emojis et caractères étendus.
    • ATTR_ERRMODE => ERRMODE_EXCEPTION : les erreurs MySQL lèvent des exceptions (plus simple à déboguer).
    • ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC : résultats sous forme de tableaux associatifs (clés = noms de colonnes).
🧭 Où placer ce fichier et comment l’utiliser ?
  • Emplacement : /config/database.php, avec le fichier .env dans le même dossier si tu gardes createImmutable(__DIR__).
  • Utilisation dans ton code (ex. public/index.php, api/*.php) :
    <?php
    require_once __DIR__.'/../config/database.php';
    $pdo = get_pdo();
    // Exemple : SELECT 1
    $stmt = $pdo->query("SELECT 1");
    echo $stmt->fetchColumn(); // affiche 1 si OK
    
📚 Définitions utiles
  • PDO (PHP Data Objects) : interface universelle pour se connecter à différentes bases (MySQL, PostgreSQL...).
  • DSN (Data Source Name) : chaîne “carte d’identité” de la connexion (type de base, hôte, nom de base, encodage).
  • phpdotenv : librairie qui lit .env et expose les variables dans $_ENV (sécurité et portabilité).
  • Fallback (??) : valeur utilisée si la clé $_ENV['...'] n’existe pas.
🧯 Erreurs fréquentes (et solutions)
  • « Class Dotenv\Dotenv not found » : assure-toi d’avoir installé vlucas/phpdotenv et d’inclure l’autoloader :
    <?php
    require_once __DIR__.'/../vendor/autoload.php'; // avant d'utiliser Dotenv
    $dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
    $dotenv->load();
    
  • « could not find driver » : l’extension pdo_mysql n’est pas activée. Active-la dans php.ini (extension=pdo_mysql), puis redémarre ton serveur.
  • « Access denied for user » : mauvais DB_USERNAME/DB_PASSWORD ou utilisateur sans droits. Crée un utilisateur dédié avec les permissions appropriées.
  • Charset non pris en compte : vérifie que ta base/tables utilisent utf8mb4 et que le DSN inclut charset=utf8mb4 (déjà présent).
🔐 Bonnes pratiques
  • Ajoute .env et /config/.env dans ton .gitignore pour ne jamais pousser d’identifiants sensibles.
  • Utilise un compte MySQL dédié avec des droits limités (principe du moindre privilège).
  • En production, place /config hors du dossier public si possible, et impose HTTPS.
  • Loggue les erreurs de connexion avec monolog (sans exposer les identifiants dans les logs).

🔧 Étape 6 : Classe src/PayPalService.php

La classe PayPalService centralise toute la logique de communication avec l’API PayPal v2 : création d’une commande (OrdersCreateRequest), récupération d’une commande (OrdersGetRequest), capture du paiement (OrdersCaptureRequest) et remboursement (CapturesRefundRequest). Elle encapsule aussi l’écriture en base (commandes, paiements) pour garder un code propre et réutilisable.

📋 Copier

<?php
// src/PayPalService.php
require_once __DIR__.'/../vendor/autoload.php';

use PayPalCheckoutSdk\Core\{PayPalHttpClient, SandboxEnvironment, ProductionEnvironment};
use PayPalCheckoutSdk\Orders\{OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest};
use PayPalCheckoutSdk\Payments\CapturesRefundRequest;

class PayPalService {
  private PayPalHttpClient $client;
  private PDO $pdo;

  public function __construct(PDO $pdo) {
    $this->pdo = $pdo;
    $mode = $_ENV['PAYPAL_MODE'] ?? 'sandbox';
    $env = ($mode === 'sandbox')
      ? new SandboxEnvironment($_ENV['PAYPAL_CLIENT_ID_SANDBOX'], $_ENV['PAYPAL_CLIENT_SECRET_SANDBOX'])
      : new ProductionEnvironment($_ENV['PAYPAL_CLIENT_ID_LIVE'], $_ENV['PAYPAL_CLIENT_SECRET_LIVE']);
    $this->client = new PayPalHttpClient($env);
  }

  public function creerCommande(int $produit_id, ?string $return_url=null, ?string $cancel_url=null) {
    $produit = $this->getProduit($produit_id);
    if (!$produit) throw new Exception("Produit introuvable");

    $req = new OrdersCreateRequest();
    $req->prefer('return=representation');
    $req->body = [
      "intent"=>"CAPTURE",
      "application_context"=>[
        "return_url"=>$return_url ?: ($_ENV['SITE_URL']."/success.php"),
        "cancel_url"=>$cancel_url ?: ($_ENV['SITE_URL']."/cancel.php"),
        "brand_name"=>"Votre Boutique","locale"=>"fr-FR",
        "landing_page"=>"BILLING","shipping_preference"=>"NO_SHIPPING","user_action"=>"PAY_NOW"
      ],
      "purchase_units"=>[[
        "reference_id"=>"PUHF",
        "description"=>$produit['description'],
        "custom_id"=>"produit_".$produit_id,
        "soft_descriptor"=>"VOTREBOUTIQUE",
        "amount"=>[
          "currency_code"=>$produit['devise'],
          "value"=>number_format($produit['prix'],2,'.',''),
          "breakdown"=>[
            "item_total"=>[
              "currency_code"=>$produit['devise'],
              "value"=>number_format($produit['prix'],2,'.','')
            ]
          ]
        ],
        "items"=>[[
          "name"=>$produit['nom'],
          "description"=>$produit['description'],
          "unit_amount"=>[
            "currency_code"=>$produit['devise'],
            "value"=>number_format($produit['prix'],2,'.','')
          ],
          "quantity"=>"1","category"=>"DIGITAL_GOODS"
        ]]
      ]]
    ];

    $res = $this->client->execute($req);
    $this->enregistrerCommande($res->result->id,$produit_id,$produit['prix'],$produit['devise'],'CREATED');
    return $res->result;
  }

  public function capturerCommande(string $order_id) {
    $req = new OrdersCaptureRequest($order_id);
    $req->prefer('return=representation');
    $res = $this->client->execute($req);
    $order = $res->result;

    $this->updateStatutCommande($order_id,$order->status);

    if (!empty($order->purchase_units[0]->payments->captures)) {
      $capture = $order->purchase_units[0]->payments->captures[0];
      $this->enregistrerPaiement($capture,$order_id);
    }
    return $order;
  }

  public function getCommande(string $order_id) {
    $req = new OrdersGetRequest($order_id);
    return $this->client->execute($req)->result;
  }

  public function rembourser(string $capture_id, ?float $montant=null) {
    $req = new CapturesRefundRequest($capture_id);
    if ($montant !== null) {
      $req->body = ["amount"=>["value"=>number_format($montant,2,'.',''),"currency_code"=>"EUR"]];
    }
    return $this->client->execute($req)->result;
  }

  public function getClientId(): string {
    $mode = $_ENV['PAYPAL_MODE'] ?? 'sandbox';
    return $mode==='sandbox' ? $_ENV['PAYPAL_CLIENT_ID_SANDBOX'] : $_ENV['PAYPAL_CLIENT_ID_LIVE'];
  }

  /* --- Helpers DB --- */
  private function getProduit(int $id) {
    $st=$this->pdo->prepare("SELECT * FROM produits WHERE id=? AND actif=1"); $st->execute([$id]); return $st->fetch();
  }
  private function enregistrerCommande($order_id,$produit_id,$montant,$devise,$statut){
    $st=$this->pdo->prepare("INSERT INTO commandes(order_id,produit_id,statut,montant_total,devise,adresse_ip,user_agent)
      VALUES(?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE statut=VALUES(statut),date_modification=CURRENT_TIMESTAMP");
    return $st->execute([$order_id,$produit_id,$statut,$montant,$devise,$_SERVER['REMOTE_ADDR']??null,$_SERVER['HTTP_USER_AGENT']??null]);
  }
  private function updateStatutCommande($order_id,$statut){
    $st=$this->pdo->prepare("UPDATE commandes SET statut=?,date_modification=CURRENT_TIMESTAMP WHERE order_id=?");
    return $st->execute([$statut,$order_id]);
  }
  private function enregistrerPaiement($capture,$order_id){
    $st=$this->pdo->prepare("SELECT id FROM commandes WHERE order_id=?"); $st->execute([$order_id]); $cmd=$st->fetch();
    if(!$cmd) return false;
    $st=$this->pdo->prepare("INSERT INTO paiements(capture_id,order_id,commande_id,statut,montant_capture,devise,donnees_paypal)
      VALUES(?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE statut=VALUES(statut),montant_capture=VALUES(montant_capture)");
    return $st->execute([$capture->id,$order_id,$cmd['id'],$capture->status,$capture->amount->value,$capture->amount->currency_code,json_encode($capture)]);
  }
}

🧠 Comprendre la logique (vue d’ensemble)

  • __construct : prépare le client PayPal dans le bon environnement (sandbox ou live) à partir des variables .env.
  • creerCommande() : lit le produit en base, prépare le panier (purchase_units) et demande à PayPal de créer une Order. On journalise la commande côté base (commandes).
  • capturerCommande() : confirme le paiement d’une Order approuvée, met à jour le statut et enregistre la Capture dans paiements.
  • getCommande() : récupère l’état courant d’une Order (utile pour du debug ou de l’admin).
  • rembourser() : initie un remboursement total ou partiel à partir d’une Capture.

🧩 Paramètres clés à connaître

  • intent: "CAPTURE" : on crée une commande dont on prévoit la capture immédiate (vs AUTHORIZE pour autoriser puis capturer plus tard).
  • application_context : URLs de retour/annulation, nom de marque, comportement d’interface (user_action: "PAY_NOW"), etc.
  • purchase_units : chaque unité représente un “panier” (montant, devise, items). Ici on gère un seul produit (quantity 1) pour la clarté.
  • category: "DIGITAL_GOODS" : indique un bien numérique (formations, e-books…). Adaptez si vous vendez des biens physiques (PHYSICAL_GOODS).
  • soft_descriptor : libellé court sur l’extrait bancaire (attention à la longueur, ~22 caractères max selon PayPal).

🛠️ Comment utiliser la classe dans vos endpoints

1) Instancier la classe une seule fois par requête et créer une commande (endpoint api/create-order.php) :

📋 Copier

<?php
require_once __DIR__.'/../config/database.php';
require_once __DIR__.'/../src/PayPalService.php';

$pdo = get_pdo();
$paypal = new PayPalService($pdo);

$input = json_decode(file_get_contents('php://input'), true);
$order = $paypal->creerCommande((int)$input['product_id']);

// Répondre à votre frontend
echo json_encode(['success'=>true,'order_id'=>$order->id]);

2) Capturer le paiement après approbation (endpoint api/capture-order.php) :

📋 Copier

<?php
require_once __DIR__.'/../config/database.php';
require_once __DIR__.'/../src/PayPalService.php';

$pdo = get_pdo();
$paypal = new PayPalService($pdo);

$input = json_decode(file_get_contents('php://input'), true);
$order = $paypal->capturerCommande($input['order_id']);

echo json_encode(['success'=>($order->status==='COMPLETED'), 'status'=>$order->status]);

🧪 Tests & bonnes pratiques

  • Testez en sandbox avec vos comptes acheteur/vendeur de test (créés automatiquement dans PayPal Developer).
  • Journalisez les requêtes/réponses (via Monolog) en cachant les données sensibles. Très utile pour le support.
  • Ne faites jamais confiance au navigateur pour confirmer un paiement : la source d’autorité reste le webhook. La capture côté serveur + vérification du webhook donne un flux robuste.
  • Devise : alignez currency_code sur celle du produit en base. Évitez les conversions “maison” (arrondis).

📚 Définitions rapides

  • Order : intention de paiement (créée puis approuvée par l’acheteur).
  • Capture : débit effectif d’une Order approuvée (argent réellement prélevé).
  • Refund : remboursement total ou partiel d’une capture existante.
⚠️ Sécurité : gardez Client Secret côté serveur uniquement, activez HTTPS en production, validez vos webhooks (signature) et nettoyez/validez toute entrée utilisateur (ID produit, order_id).

🎨 Étape 7 : Interface utilisateur moderne (HTML + JS)

L’interface ci-dessous affiche vos produits et rend le bouton PayPal via le PayPal JavaScript SDK. Le flux est : affichercréer la commande (AJAX)approuver côté PayPalcapturer côté serveur (AJAX)afficher un message clair.

📋 Copier

… (ton bloc HTML/PHP/JS inchangé) …

🧠 Ce que fait chaque partie

  • Chargement du SDK : la balise <script src="https://www.paypal.com/sdk/js?client-id=...&currency=EUR&locale=fr_FR"> charge le SDK et expose paypal.Buttons() dans la page.
  • client-id : récupéré via $paypal->getClientId() (sandbox ou live selon PAYPAL_MODE).
  • currency / locale : contrôlent la devise affichée et la langue/formatage.
  • createOrder() : appelle /api/create-order.php (POST JSON) et retourne l’order_id PayPal à partir de la réponse.
  • onApprove() : après l’approbation par l’acheteur, appelle /api/capture-order.php pour capturer le paiement côté serveur.
  • onError() : affiche un message d’erreur lisible (ex. réseau, refus, config invalide).

🧩 Définition rapide des options du bouton

  • style : personnalise l’apparence (layout, shape, label…).
  • createOrder : fonction qui doit retourner l’orderID créé par votre serveur.
  • onApprove : appelée quand l’utilisateur a approuvé le paiement ; vous y déclenchez la capture côté serveur.
  • onError : gestion centralisée des erreurs d’UI (réseau, SDK, exceptions serveur sérialisées).

🔐 Sécurité & intégrité des montants

  • Le prix ne doit pas venir du navigateur : seul l’ID produit est envoyé à create-order.php. Le serveur lit le prix en base.
  • Vérifiez/validez product_id côté serveur (entier, produit actif).
  • Ne divulguez jamais vos secrets dans le front : seul le Client ID est public.
  • Activez HTTPS en production, sinon le SDK et vos appels fetch peuvent échouer ou être bloqués.

🎛️ Amélioration UX (désactiver les actions pendant les appels, message clair)

📋 Copier

<script>
// Exemple : désactiver la zone carte pendant l'appel, afficher un "chargement"
function renderPayPalButton(productId){
  const card = document.querySelector('[data-product="'+productId+'"]');
  const ok = document.getElementById('ok-'+productId);
  const ko = document.getElementById('ko-'+productId);

  function setBusy(b){
    card.style.opacity = b ? 0.6 : 1;
    card.style.pointerEvents = b ? 'none' : 'auto';
  }

  paypal.Buttons({
    style:{ layout:'vertical', shape:'rect', label:'paypal', tagline:false },

    createOrder: async () => {
      try{
        setBusy(true);
        const r = await fetch('/api/create-order.php',{
          method:'POST', headers:{'Content-Type':'application/json'},
          body: JSON.stringify({product_id:productId})
        });
        const data = await r.json();
        if(!data.success) throw new Error(data.error || 'create-order failed');
        return data.order_id;
      } catch(e){
        ko.textContent = '❌ '+e.message;
        ko.style.display = 'block';
        throw e; // obligatoire : re-propager pour que le SDK sache que c'est en erreur
      } finally {
        setBusy(false);
      }
    },

    onApprove: async (data) => {
      try{
        setBusy(true);
        const r = await fetch('/api/capture-order.php',{
          method:'POST', headers:{'Content-Type':'application/json'},
          body: JSON.stringify({order_id:data.orderID})
        });
        const res = await r.json();
        if(res.success){
          ok.style.display='block'; ko.style.display='none';
        }else{
          ko.textContent = '❌ '+(res.error || 'Capture échouée');
          ko.style.display='block'; ok.style.display='none';
        }
      } catch(e){
        ko.textContent = '❌ '+e.message;
        ko.style.display='block'; ok.style.display='none';
      } finally {
        setBusy(false);
      }
    },

    onError: (err) => {
      ko.textContent='❌ '+err.message;
      ko.style.display='block'; 
    }
  }).render('#paypal-btn-'+productId);
}
renderPayPalButton(1);
renderPayPalButton(2);
</script>

🌍 Changer de devise / langue (exemples)

Modifiez simplement les paramètres du SDK dans la balise script :

📋 Copier

<!-- USD en anglais -->
<script src="https://www.paypal.com/sdk/js?client-id=VOTRE_CLIENT_ID&currency=USD&locale=en_US"></script>

<!-- MAD en français -->
<script src="https://www.paypal.com/sdk/js?client-id=VOTRE_CLIENT_ID&currency=MAD&locale=fr_FR"></script>

🔗 Appels AJAX : entêtes et CORS

Dans vos endpoints PHP, gardez des entêtes simples et claires (tu les as déjà) :

📋 Copier

header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if($_SERVER['REQUEST_METHOD']==='OPTIONS') exit;

🧪 Tests en sandbox : à connaître

  • Créez/obtenez vos comptes de test (acheteur/vendeur) depuis PayPal Developer > Sandbox > Accounts.
  • Connectez-vous côté acheteur avec l’email sandbox (pas votre email personnel) pendant l’approbation PayPal.
  • Simulez des cas : refus/annulation, devise différente, réseau lent… pour vérifier vos messages UI.

🩺 Débogage : erreurs fréquentes

  • “Client ID is invalid” : mauvais identifiant, ou incohérence PAYPAL_MODE (sandbox vs live).
  • “create-order failed” : vérifiez la réponse JSON de create-order.php et vos logs (Monolog).
  • Capture échouée : assurez-vous que order_id envoyé à capture-order.php correspond à la commande créée.
  • Rien ne s’affiche : script SDK bloqué (HTTPS manquant, adblock), ou paypal-btn-* introuvable.

✅ Règles d’or UX

  • Donnez un feedback immédiat (état “en cours”, succès, échec).
  • Évitez les doubles clics (désactivez la carte ou le bouton pendant l’appel réseau).
  • Placez les messages près du bouton concerné (clarté visuelle).

🔗 Étape 8 : Endpoints API (create / capture)

Le front (boutons PayPal) appelle deux endpoints backend :

  1. create-order.php : le serveur crée une Order PayPal à partir d’un product_id (le prix réel est relu depuis la base).
  2. capture-order.php : une fois l’Order approuvée par l’acheteur, le serveur capture le paiement (débit effectif) et enregistre la transaction.

Flux complet : UI ➜ create-order (serveur) ➜ PayPal crée l’Order ➜ acheteur approuve ➜ UI ➜ capture-order (serveur) ➜ PayPal capture ➜ base mise à jour.

📝 api/create-order.php

📋 Copier

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if($_SERVER['REQUEST_METHOD']==='OPTIONS') exit;

require_once __DIR__.'/../vendor/autoload.php';
require_once __DIR__.'/../config/database.php';
require_once __DIR__.'/../src/PayPalService.php';

try {
  if($_SERVER['REQUEST_METHOD']!=='POST') throw new Exception('Méthode non autorisée');
  $in = json_decode(file_get_contents('php://input'), true);
  if(!$in || empty($in['product_id'])) throw new Exception('ID produit manquant');

  $pdo = get_pdo();
  $paypal = new PayPalService($pdo);
  $order = $paypal->creerCommande((int)$in['product_id']);

  echo json_encode(['success'=>true,'order_id'=>$order->id,'status'=>$order->status,'links'=>$order->links]);
} catch(Exception $e){
  http_response_code(400);
  echo json_encode(['success'=>false,'error'=>$e->getMessage()]);
}
🧠 À retenir (create-order)
  • On reçoit product_id du front (jamais le prix). Le serveur relit le prix et la devise en base pour éviter les manipulations côté client.
  • On renvoie au front order_id (PayPal). Le bouton PayPal s’en sert pour poursuivre le flux.
  • Réponses JSON uniformes : { success, order_id, status, links } ou { success:false, error }.
🧪 Test rapide (cURL)

📋 Copier

curl -X POST http://localhost:8000/api/create-order.php \
  -H "Content-Type: application/json" \
  -d '{"product_id":1}'

💰 api/capture-order.php

📋 Copier

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if($_SERVER['REQUEST_METHOD']==='OPTIONS') exit;

require_once __DIR__.'/../vendor/autoload.php';
require_once __DIR__.'/../config/database.php';
require_once __DIR__.'/../src/PayPalService.php';

try {
  if($_SERVER['REQUEST_METHOD']!=='POST') throw new Exception('Méthode non autorisée');
  $in = json_decode(file_get_contents('php://input'), true);
  if(!$in || empty($in['order_id'])) throw new Exception('ID commande manquant');

  $pdo = get_pdo();
  $paypal = new PayPalService($pdo);

  // Vérifier l'existence de la commande
  $st = $pdo->prepare("SELECT * FROM commandes WHERE order_id=?");
  $st->execute([$in['order_id']]);
  $cmd = $st->fetch(PDO::FETCH_ASSOC);
  if(!$cmd) throw new Exception('Commande introuvable');

  $order = $paypal->capturerCommande($in['order_id']);

  if($order->status==='COMPLETED'){
    // Mettre à jour email/nom si disponibles
    if(!empty($order->payer->email_address)){
      $nom = '';
      if(isset($order->payer->name)){
        $nom = trim(($order->payer->name->given_name ?? '').' '.($order->payer->name->surname ?? ''));
      }
      $st = $pdo->prepare("UPDATE commandes SET email_acheteur=?, nom_acheteur=? WHERE order_id=?");
      $st->execute([$order->payer->email_address,$nom,$in['order_id']]);
    }

    // ID de transaction
    $tx = null;
    if(!empty($order->purchase_units[0]->payments->captures)){
      $tx = $order->purchase_units[0]->payments->captures[0]->id;
    }

    // (Optionnel) actions post-paiement côté serveur/webhook
    echo json_encode(['success'=>true,'order_id'=>$order->id,'status'=>$order->status,'transaction_id'=>$tx,'payer_email'=>$order->payer->email_address ?? null]);
  } else {
    throw new Exception('Échec de la capture: '.$order->status);
  }
} catch(Exception $e){
  http_response_code(400);
  echo json_encode(['success'=>false,'error'=>$e->getMessage()]);
}
🧠 À retenir (capture-order)
  • On vérifie que l’order_id existe en base (créée précédemment). Sinon, on refuse la capture.
  • Après capture, on met à jour les infos de l’acheteur si PayPal les fournit (email + nom) et on récupère l’ID de transaction (capture_id).
  • La réponse JSON renvoie success, order_id, status, et éventuellement transaction_id, payer_email.
🧪 Test rapide (cURL en 2 temps)

📋 Copier

# 1) Créer l'Order (récupérer ORDER_ID)
curl -s -X POST http://localhost:8000/api/create-order.php \
  -H "Content-Type: application/json" \
  -d '{"product_id":1}'

# 2) Capturer (simuler après approbation)
curl -X POST http://localhost:8000/api/capture-order.php \
  -H "Content-Type: application/json" \
  -d '{"order_id":"ORDER_ID"}'

🔐 Sécurité & bonnes pratiques backend

  • Validation stricte : castez product_id en entier, vérifiez que le produit est actif ; validez la présence de order_id.
  • Montants côté serveur : le prix vient de la base, pas du navigateur (évite toute triche).
  • Idempotence : la capture peut être appelée 2x (rechargement, latence). Votre code gère déjà un upsert sur paiements (clé unique capture_id), ce qui évite les doublons.
  • Journalisation : logguez les erreurs et réponses PayPal (sans secrets) pour diagnostiquer rapidement.
  • CORS & méthodes : vous avez un CORS minimal + gestion de OPTIONS. Restreignez Access-Control-Allow-Origin à votre domaine en production.
  • HTTPS obligatoire en production (SDK PayPal, cookies, sécurité réseau).

📦 Formats JSON (exemples de réponses)

📋 Copier

/* create-order: succès */
{
  "success": true,
  "order_id": "5N123456789...",
  "status": "CREATED",
  "links": [ ... ]
}

/* create-order: erreur */
{
  "success": false,
  "error": "ID produit manquant"
}

/* capture-order: succès */
{
  "success": true,
  "order_id": "5N123456789...",
  "status": "COMPLETED",
  "transaction_id": "1AB23456CD...",
  "payer_email": "buyer-sbx@example.com"
}

/* capture-order: erreur */
{
  "success": false,
  "error": "Commande introuvable"
}

📚 Définitions utiles

  • Order : intention de paiement créée chez PayPal (peut être approuvée puis capturée).
  • Capture : débit effectif de l’Order (argent réellement prélevé).
  • Idempotence : principe qui permet d’exécuter plusieurs fois une même action sans effets indésirables (pas de double débit).
  • CORS : règles de partage de ressources entre origines ; autorisez explicitement votre domaine en production.
👉 Astuce pro : Même si le front affiche “succès”, la source d’autorité reste le webhook PayPal. Utilisez-le pour déclencher les actions sensibles (envoi des accès/produits), et considérez la réponse de capture-order comme un feedback immédiat pour l’UX.

📧 Étape 9 : Webhooks PayPal (signature & traitements)

Le webhook est le canal officiel par lequel PayPal vous informe en temps réel des événements : CHECKOUT.ORDER.APPROVED, CHECKOUT.ORDER.COMPLETED, PAYMENT.CAPTURE.COMPLETED, ...REFUNDED, etc. C’est votre source d’autorité : même si l’UI affiche “succès”, c’est le webhook qui doit déclencher les actions sensibles (activation d’accès, envoi d’email/licence, etc.).

Flux : PayPal ➜ (POST JSON + en-têtes de signature) ➜ votre public/webhook.php ➜ validations ➜ mises à jour BDD ➜ actions post-paiement.

🔔 public/webhook.php

📋 Copier

<?php
require_once __DIR__.'/../vendor/autoload.php';
require_once __DIR__.'/../config/database.php';
require_once __DIR__.'/../src/PayPalService.php';
require_once __DIR__.'/../src/WebhookHandler.php';

$pdo = get_pdo();
$handler = new WebhookHandler($pdo);
$handler->handleWebhook();

⚙️ src/WebhookHandler.php

📋 Copier

<?php
// src/WebhookHandler.php
use PayPalCheckoutSdk\Core\{PayPalHttpClient, SandboxEnvironment, ProductionEnvironment};

class WebhookHandler {
  private PDO $pdo;
  private PayPalService $paypal;

  public function __construct(PDO $pdo){
    $this->pdo = $pdo;
    $this->paypal = new PayPalService($pdo);
  }

  public function handleWebhook(){
    try{
      $payload = file_get_contents('php://input');
      $data = json_decode($payload,true);
      if(!$data) throw new Exception('Données webhook invalides');

      $signatureOk = $this->verifierSignature($payload);
      $this->logWebhook($data, $signatureOk);

      if(!$signatureOk){
        throw new Exception('Signature PayPal invalide');
      }

      $type = $data['event_type'] ?? '';

      switch($type){
        case 'CHECKOUT.ORDER.APPROVED':
          $this->updateOrderStatus($data['resource']['id'] ?? '', 'APPROVED');
          break;

        case 'CHECKOUT.ORDER.COMPLETED':
          $this->updateOrderStatus($data['resource']['id'] ?? '', 'COMPLETED');
          break;

        case 'PAYMENT.CAPTURE.COMPLETED':
          $this->onCaptureCompleted($data);
          break;

        case 'PAYMENT.CAPTURE.DENIED':
        case 'PAYMENT.CAPTURE.DECLINED':
          $this->onCaptureFailed($data);
          break;

        case 'PAYMENT.CAPTURE.REFUNDED':
          $this->onRefunded($data);
          break;

        default:
          error_log("Webhook non géré: ".$type);
      }

      $this->markWebhookProcessed($data['id'] ?? '');
      http_response_code(200);
      echo json_encode(['status'=>'ok']);
    } catch(Exception $e){
      error_log('Webhook error: '.$e->getMessage());
      http_response_code(400);
      echo json_encode(['error'=>$e->getMessage()]);
    }
  }

  /* --- Vérification de signature (à implémenter réellement en prod) --- */
  private function verifierSignature(string $payload): bool {
    $headers = function_exists('getallheaders') ? getallheaders() : [];
    // TODO: implémenter l'appel "Verify Webhook Signature" de PayPal (voir bloc dédié plus bas)
    return !empty($headers['PAYPAL-TRANSMISSION-ID']); // simplifié pour l'exemple
  }

  private function onCaptureCompleted(array $data): void {
    $capture = $data['resource'] ?? [];
    $order_id = $capture['supplementary_data']['related_ids']['order_id'] ?? '';
    if(!$order_id) return;

    $this->updateOrderStatus($order_id,'COMPLETED');
    $this->saveCapture($order_id,$capture);
    $this->executePostPaymentActions($order_id);
  }

  private function onCaptureFailed(array $data): void {
    $capture = $data['resource'] ?? [];
    $order_id = $capture['supplementary_data']['related_ids']['order_id'] ?? '';
    if($order_id){
      $this->updateOrderStatus($order_id,'FAILED');
      $this->notifyPaymentFailure($order_id);
    }
  }

  private function onRefunded(array $data): void {
    $refund = $data['resource'] ?? [];
    if(!empty($refund['links'])){
      foreach($refund['links'] as $l){
        if(preg_match('~captures/([^/]+)~',$l['href'] ?? '', $m)){
          $this->updateRefundStatus($m[1], $refund);
        }
      }
    }
  }

  /* --- Actions --- */
  private function saveCapture(string $order_id, array $capture): void {
    $st=$this->pdo->prepare("SELECT id FROM commandes WHERE order_id=?"); $st->execute([$order_id]); $cmd=$st->fetch();
    if(!$cmd) return;
    $st=$this->pdo->prepare("INSERT INTO paiements(capture_id,order_id,commande_id,statut,montant_capture,devise,donnees_paypal)
      VALUES(?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE statut=VALUES(statut), donnees_paypal=VALUES(donnees_paypal)");
    $st->execute([
      $capture['id'] ?? null,
      $order_id,
      $cmd['id'],
      $capture['status'] ?? 'COMPLETED',
      $capture['amount']['value'] ?? 0,
      $capture['amount']['currency_code'] ?? 'EUR',
      json_encode($capture)
    ]);
  }

  private function executePostPaymentActions(string $order_id): void {
    $st=$this->pdo->prepare("SELECT c.*, p.nom AS produit_nom, p.description FROM commandes c LEFT JOIN produits p ON p.id=c.produit_id WHERE c.order_id=?");
    $st->execute([$order_id]);
    $cmd=$st->fetch();
    if(!$cmd) return;

    if(!empty($cmd['email_acheteur'])){
      $this->envoyerEmailConfirmation($cmd);
    }
    $this->activerProduit($cmd);
  }

  private function envoyerEmailConfirmation(array $cmd): void {
    $to = $cmd['email_acheteur'];
    $subject = "Confirmation d'achat - #".$cmd['order_id'];
    $message = "
    <html><head><meta charset='utf-8'><title>Confirmation</title></head><body>
      <h2>Merci pour votre achat !</h2>
      <p>Bonjour ".htmlspecialchars($cmd['nom_acheteur'] ?: 'Client').",</p>
      <p>Votre paiement a bien été confirmé.</p>
      <ul>
        <li><strong>Produit :</strong> ".htmlspecialchars($cmd['produit_nom'])."</li>
        <li><strong>Montant :</strong> ".number_format((float)$cmd['montant_total'],2)." ".$cmd['devise']."</li>
        <li><strong>Commande :</strong> ".htmlspecialchars($cmd['order_id'])."</li>
        <li><strong>Date :</strong> ".date('d/m/Y H:i', strtotime($cmd['date_creation']))."</li>
      </ul>
      <p>Un email séparé peut contenir les instructions d'accès/lien de téléchargement.</p>
      <p>Cordialement,<br>Votre Boutique</p>
    </body></html>";
    $headers = [
      'MIME-Version: 1.0',
      'Content-type: text/html; charset=UTF-8',
      'From: noreply@votreboutique.com',
      'Reply-To: support@votreboutique.com'
    ];
    @mail($to,$subject,$message,implode("\r\n",$headers));
  }

  private function activerProduit(array $cmd): void {
    switch((int)$cmd['produit_id']){
      case 1: $this->genererAccesFormation($cmd); break;
      case 2: $this->envoyerLienTelechargement($cmd); break;
      default: /* autres produits */ break;
    }
  }

  private function genererAccesFormation(array $cmd): void {
    // Exemple: insérer un code d'accès unique (à créer: table acces_formations)
    // CREATE TABLE acces_formations(id INT AUTO_INCREMENT PRIMARY KEY, commande_id INT, code VARCHAR(64), date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY(code));
    $code = bin2hex(random_bytes(16));
    try{
      $st=$this->pdo->prepare("INSERT INTO acces_formations(commande_id,code) VALUES(?,?)");
      $st->execute([$cmd['id'],$code]);
    } catch(Exception $e){ /* table optionnelle, ignorer si absente */ }
  }

  private function envoyerLienTelechargement(array $cmd): void {
    if(empty($cmd['email_acheteur'])) return;
    $dl = "https://votre-domaine.com/downloads/ebook-js.zip"; // à adapter
    $subject = "Votre e-book est prêt - commande #".$cmd['order_id'];
    $message = "Bonjour,\n\nMerci pour votre achat. Téléchargez votre e-book ici : ".$dl."\n\nCordialement.";
    @mail($cmd['email_acheteur'], $subject, $message, "From: noreply@votreboutique.com\r\n");
  }

  /* --- Utilitaires Webhook --- */
  private function updateOrderStatus(string $order_id, string $status): void {
    if(!$order_id) return;
    $st=$this->pdo->prepare("UPDATE commandes SET statut=?, date_modification=CURRENT_TIMESTAMP WHERE order_id=?");
    $st->execute([$status,$order_id]);
  }

  private function markWebhookProcessed(string $webhook_id): void {
    if(!$webhook_id) return;
    $st=$this->pdo->prepare("UPDATE webhooks_log SET traite=1, date_traitement=CURRENT_TIMESTAMP WHERE webhook_id=?");
    $st->execute([$webhook_id]);
  }

  private function logWebhook(array $data, bool $signatureOk): void {
    $st=$this->pdo->prepare("INSERT INTO webhooks_log(webhook_id,event_type,resource_type,resource_id,donnees_webhook,signature_valide,traite)
      VALUES(?,?,?,?,?,?,0)");
    $st->execute([
      $data['id'] ?? null,
      $data['event_type'] ?? '',
      $data['resource_type'] ?? '',
      $data['resource']['id'] ?? '',
      json_encode($data),
      (int)$signatureOk
    ]);
  }

  private function notifyPaymentFailure(string $order_id): void {
    error_log("Paiement échoué pour la commande ".$order_id);
  }

  private function updateRefundStatus(string $capture_id, array $refund): void {
    $st=$this->pdo->prepare("UPDATE paiements SET statut='REFUNDED', donnees_paypal=? WHERE capture_id=?");
    $st->execute([json_encode($refund), $capture_id]);
  }
}

🔐 Vérifier la signature du webhook (implémentation réelle)

En production, vous devez valider la signature PayPal avec l’API Verify Webhook Signature. Voici un squelette à insérer dans verifierSignature() pour remplacer la version simplifiée :

📋 Copier

private function verifierSignature(string $payload): bool {
  $headers = function_exists('getallheaders') ? getallheaders() : [];
  $required = ['PAYPAL-TRANSMISSION-ID','PAYPAL-TRANSMISSION-TIME','PAYPAL-TRANSMISSION-SIG','PAYPAL-CERT-URL','PAYPAL-AUTH-ALGO'];
  foreach($required as $h){ if(empty($headers[$h])) return false; }

  $body = [
    'transmission_id'   => $headers['PAYPAL-TRANSMISSION-ID'],
    'transmission_time' => $headers['PAYPAL-TRANSMISSION-TIME'],
    'cert_url'          => $headers['PAYPAL-CERT-URL'],
    'auth_algo'         => $headers['PAYPAL-AUTH-ALGO'],
    'transmission_sig'  => $headers['PAYPAL-TRANSMISSION-SIG'],
    'webhook_id'        => $_ENV['PAYPAL_WEBHOOK_ID'],
    'webhook_event'     => json_decode($payload, true)
  ];

  // Obtenir un token OAuth2 auprès de PayPal (via SDK ou requête simple)
  $mode = $_ENV['PAYPAL_MODE'] ?? 'sandbox';
  $base = $mode==='sandbox' ? 'https://api.sandbox.paypal.com' : 'https://api.paypal.com';

  // Token client-credentials
  $ch = curl_init($base.'/v1/oauth2/token');
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_USERPWD        => ($mode==='sandbox' ? $_ENV['PAYPAL_CLIENT_ID_SANDBOX'].':'.$_ENV['PAYPAL_CLIENT_SECRET_SANDBOX'] : $_ENV['PAYPAL_CLIENT_ID_LIVE'].':'.$_ENV['PAYPAL_CLIENT_SECRET_LIVE']),
    CURLOPT_POSTFIELDS     => 'grant_type=client_credentials'
  ]);
  $tokRes = json_decode(curl_exec($ch), true);
  curl_close($ch);
  if(empty($tokRes['access_token'])) return false;

  // Appel Verify Webhook Signature
  $ch = curl_init($base.'/v1/notifications/verify-webhook-signature');
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json','Authorization: Bearer '.$tokRes['access_token']],
    CURLOPT_POSTFIELDS     => json_encode($body)
  ]);
  $res = json_decode(curl_exec($ch), true);
  curl_close($ch);

  return isset($res['verification_status']) && $res['verification_status']==='SUCCESS';
}
À savoir : PayPal peut renvoyer un webhook en retry si votre serveur n’a pas répondu 200 assez vite. Répondez rapidement (journalisez puis traitez), et faites des opérations idempotentes (par ex., INSERT ... ON DUPLICATE KEY UPDATE sur vos tables).

🧪 Tests & mise au point

  • URL publique : en local, exposez public/webhook.php avec un tunnel (ex. ngrok) et déclarez l’URL dans PayPal Developer.
  • Simulateur : depuis le dashboard Developer, envoyez des événements de test à votre URL pour vérifier la signature et vos traitements.
  • Logs : enregistrez le JSON brut du webhook (table webhooks_log) + statut de validation pour rejouer/déboguer facilement.

📚 Définitions utiles

  • Webhook : appel HTTP envoyé par PayPal vers votre serveur pour notifier un événement.
  • Signature : en-têtes cryptographiques permettant de prouver que l’appel vient bien de PayPal.
  • Idempotence : possibilité de traiter plusieurs fois le même événement sans effet de bord (pas de double activation ni double e-mail).
  • Retry : PayPal renvoie l’événement si votre serveur ne répond pas 200 ou si le réseau échoue.
👉 Résumé : Le webhook est le cœur de la fiabilité. 1) Validez la signature, 2) journalisez, 3) traitez vos actions idempotentes, 4) répondez vite en 200. Ainsi, vos livraisons de produit/accès ne dépendent pas de l’UI et restent exactes même en cas de latence côté client.

📄 Pages de redirection après paiement

Lorsqu’un utilisateur effectue un paiement via PayPal, il est indispensable de l’informer clairement du résultat de la transaction. Pour cela, on définit deux pages de redirection : success.php (si le paiement a réussi) et cancel.php (si l’utilisateur annule ou si la transaction échoue).

Ces pages ne gèrent pas directement la logique de paiement — ce travail est fait par l’API PayPal et vos endpoints (create-order, capture-order). Elles servent uniquement à donner un retour visuel et rassurant à l’utilisateur. C’est une bonne pratique UX (expérience utilisateur) et aussi un moyen de réduire les litiges en informant clairement vos clients.

✅ success.php

Cette page est affichée lorsque PayPal confirme que le paiement est validé. Elle doit :

  • Rassurer l’utilisateur (message de confirmation clair et positif)
  • Préciser qu’un email de confirmation a été envoyé
  • Fournir un bouton pour retourner à l’accueil ou continuer sa navigation

📋 Copier le code

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Paiement réussi</title>
  <style>
    body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f9f9f9; }
    h1 { color: #28a745; }
    a { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #28a745; color: #fff; text-decoration: none; border-radius: 5px; }
    a:hover { background: #218838; }
  </style>
</head>
<body>
  <h1>✅ Paiement réussi</h1>
  <p>Merci pour votre achat ! Votre commande a été confirmée et un email de confirmation vous a été envoyé.</p>
  <a href="/">Retour à l’accueil</a>
</body>
</html>
  

👉 Dans vos scripts PHP (par exemple capture-order.php), vous pouvez rediriger vers cette page avec :

header("Location: /success.php");
exit;
  

❌ cancel.php

Cette page est affichée si l’utilisateur choisit d’annuler son paiement ou si une erreur survient. Elle doit :

  • Informer l’utilisateur que rien n’a été débité
  • Proposer un bouton pour retourner à la boutique
  • Encourager à réessayer ou choisir un autre produit

📋 Copier le code

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Paiement annulé</title>
  <style>
    body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f9f9f9; }
    h1 { color: #dc3545; }
    a { display: inline-block; margin-top: 20px; padding: 10px 20px; background: #007bff; color: #fff; text-decoration: none; border-radius: 5px; }
    a:hover { background: #0056b3; }
  </style>
</head>
<body>
  <h1>❌ Paiement annulé</h1>
  <p>Votre paiement n’a pas été finalisé. Aucun montant n’a été débité.</p>
  <p>Vous pouvez réessayer ou revenir à la boutique.</p>
  <a href="/">Retour à la boutique</a>
</body>
</html>
  

👉 De la même façon, dans vos scripts PHP, vous pouvez rediriger vers cette page avec :

header("Location: /cancel.php");
exit;
  

🎯 Pourquoi ces pages sont importantes ?

  • Transparence : l’utilisateur sait immédiatement si son paiement est validé ou non.
  • Confiance : afficher une confirmation claire réduit les doutes et les litiges.
  • Navigation : proposer un bouton de retour évite que l’utilisateur se perde après une transaction.
  • Expérience utilisateur (UX) : rassurer, guider et garder une cohérence visuelle avec votre site.

✅ Conclusion

Tu as maintenant une intégration PayPal v2 complète : configuration Developer, SDK PHP + JavaScript, base MySQL, classe PayPalService, interface moderne avec boutons PayPal, endpoints create-order / capture-order, et webhooks (journalisés + actions post-paiement). Tu peux passer en production en remplaçant les identifiants sandbox par les identifiants live, en activant HTTPS et en implémentant la vérification officielle de la signature des webhooks (endpoint “Verify Webhook Signature” PayPal).

🔗 Tutoriels connexes sur Oujood :

❓ FAQ – Questions fréquentes

1. Quelle est la différence entre sandbox et live ?

Sandbox est l’environnement de test PayPal : il simule les paiements sans argent réel. Live est l’environnement de production : les transactions sont réelles et votre compte PayPal est débité/crédité.

2. Où trouver mon Client ID et Secret PayPal ?

Ils se trouvent dans votre Dashboard PayPal Developer, dans la section My Apps & Credentials, après avoir créé une application.

3. Les prix doivent-ils venir du client ou du serveur ?

Toujours du serveur. Le navigateur n’envoie que l’ID du produit. Le serveur vérifie en base de données le prix exact, ce qui évite toute manipulation malveillante.

4. Les webhooks sont-ils obligatoires ?

Oui, pour garantir l’intégrité. Même si le client ferme la page trop tôt ou si la connexion échoue, PayPal envoie un webhook qui confirme le paiement. C’est la source de vérité.