Tutoriel · PayPal · Salesforce · Apex
Déclencher un paiement PayPal depuis Salesforce
Un conseiller clique dans Salesforce, le client reçoit un lien PayPal : appel Apex, commande idempotente, approbation, capture sûre et statut remonté dans le CRM.
Le parcours
Un conseiller d’Acme Assurances, entreprise fictive, ouvre un enregistrement Paiement__c au statut « À payer » et clique sur « Demander un paiement PayPal ». Le parcours complet :
- Salesforce appelle le middleware avec le seul identifiant du paiement ;
- le middleware relit le paiement dans Salesforce, crée une commande PayPal et renvoie un lien d’approbation ;
- le lien est envoyé au client, par e-mail ou dans son espace ;
- le client paie sur PayPal, qui le renvoie vers le middleware ;
- le middleware capture la commande, c’est-à-dire encaisse réellement ;
- le webhook de capture met à jour Salesforce, comme vu dans l’article précédent.
Côté Salesforce : une action Apex
Une méthode invocable, appelée par un Screen Flow lancé depuis un bouton, fait l’appel au middleware. L’URL et l’authentification sont portées par une Named Credential : le code ne contient aucun secret.
public with sharing class DemanderPaiementPayPal {
@InvocableMethod(label='Demander un paiement PayPal' callout=true)
public static List<String> demander(List<Id> paiementIds) {
List<String> liens = new List<String>();
for (Id paiementId : paiementIds) { // l'action est lancée depuis un bouton : un enregistrement à la fois
HttpRequest req = new HttpRequest();
// La Named Credential porte l'URL et l'authentification : aucun secret dans le code.
req.setEndpoint('callout:Middleware_Paiements/paiements/' + paiementId + '/paypal');
req.setMethod('POST');
req.setTimeout(20000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('Middleware ' + res.getStatusCode() + ' : ' + res.getBody());
}
Map<String, Object> corps = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
liens.add((String) corps.get('lien'));
}
return liens;
}
} Le flow affiche ensuite le lien au conseiller ou déclenche l’e-mail. L’Apex n’envoie ni montant ni devise : seulement l’identifiant. Pour un traitement en masse, l’appel passerait dans un Queueable, selon les principes de l’article sur l’Apex asynchrone.
Côté middleware : ne faire confiance qu’à Salesforce
Le point d’accès est authentifié (le jeton de la Named Credential) mais il reste une surface d’attaque : un appelant ne doit pas pouvoir fixer le montant. Le middleware relit donc le paiement dans Salesforce avec le client Java et vérifie son statut avant tout.
@RestController
@RequestMapping("/paiements")
public class DemandePaiementController {
private final SalesforceClient salesforce;
private final PayPalClient paypal;
public record Reponse(String orderId, String lien) {}
// Constructeur omis.
@PostMapping("/{paiementId}/paypal")
public Reponse demander(@PathVariable @Pattern(regexp = "^[a-zA-Z0-9]{18}$") String paiementId) {
// Montant, devise et statut sont relus dans Salesforce : on ne les accepte pas de l'appelant.
PaiementLu p = salesforce.lirePaiement(paiementId);
if (!"A_payer".equals(p.statut())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Paiement déjà engagé : " + p.statut());
}
Commande c = paypal.creerCommande(p);
salesforce.mettreAJourPaiement(paiementId, Map.of(
"PayPal_Order_Id__c", c.id(),
"Lien_Paiement__c", c.lienApprobation()));
return new Reponse(c.id(), c.lienApprobation());
}
} Le statut « À payer » empêche de redemander un paiement déjà engagé ; une nouvelle demande pour un paiement abandonné passe par une remise à « À payer » explicite, tracée dans Salesforce.
Créer la commande PayPal
Avec l’API Orders v2, une commande en intent: CAPTURE décrit ce qui sera encaissé en une fois. Le bloc experience_context règle la page de paiement et les URL de retour.
public Commande creerCommande(PaiementLu p) {
Map<String, Object> corps = Map.of(
"intent", "CAPTURE",
"purchase_units", List.of(Map.of(
"reference_id", p.reference(), // PAI-000123, lisible dans le tableau de bord PayPal
"custom_id", p.id(), // identifiant Salesforce, renvoyé dans les événements
"invoice_id", p.reference(), // unique : PayPal refuse un second paiement de la même facture
"description", "Acme Assurances : " + p.libelle(),
"amount", Map.of("currency_code", p.devise(), "value", p.montant().toPlainString()))),
"payment_source", Map.of("paypal", Map.of("experience_context", Map.of(
"brand_name", "Acme Assurances",
"user_action", "PAY_NOW",
"return_url", "https://paiements.acme-demo.example/paypal/retour",
"cancel_url", "https://paiements.acme-demo.example/paypal/annulation")))
);
CommandePayPal r = http.post().uri("/v2/checkout/orders")
// Même clé pour le même paiement : un double clic renvoie la même commande au lieu d'en créer deux.
.header("PayPal-Request-Id", "commande-" + p.id())
.contentType(MediaType.APPLICATION_JSON)
.body(corps)
.retrieve()
.body(CommandePayPal.class);
String lien = r.links().stream()
.filter(l -> "payer-action".equals(l.rel()))
.findFirst().orElseThrow().href();
return new Commande(r.id(), lien);
} PayPal-Request-Idrend la création idempotente : la même clé renvoie la même commande pendant la durée de rétention de PayPal ;custom_idporte l’identifiant Salesforce : chaque événement lié à cette commande le contiendra ;invoice_id, unique par compte PayPal, interdit un second encaissement de la même référence ;- le montant est envoyé en chaîne (
"120.00"), sans passer par undoublequi arrondirait.
Le lien de type payer-action est celui à transmettre au client. Une commande non approuvée expire d’elle-même : un paiement resté sans suite se repère dans Salesforce et se relance.
Capturer, une seule fois
L’approbation n’est pas l’encaissement. Tant que la commande n’est pas capturée, aucun argent n’a bougé. Le client revient sur l’URL de retour, avec l’identifiant de commande en paramètre token :
@GetMapping("/paypal/retour")
public RedirectView retour(@RequestParam("token") String orderId) {
// PayPal renvoie le client ici après approbation, avec l'identifiant de commande dans « token ».
CaptureResultat r = paypal.capturer(orderId);
return new RedirectView(switch (r.statut()) {
case "COMPLETED" -> "https://www.acme-demo.example/paiement/merci";
case "PENDING" -> "https://www.acme-demo.example/paiement/en-cours"; // contrôle PayPal en cours
default -> "https://www.acme-demo.example/paiement/refuse";
});
} La capture peut être demandée deux fois : par la page de retour et par le webhook. Elle est protégée de deux façons, une clé PayPal-Request-Id dédiée et la prise en compte de l’erreur ORDER_ALREADY_CAPTURED comme un succès :
public CaptureResultat capturer(String orderId) {
try {
return http.post().uri("/v2/checkout/orders/{id}/capture", orderId)
.header("PayPal-Request-Id", "capture-" + orderId)
.contentType(MediaType.APPLICATION_JSON)
.retrieve()
.body(CaptureResultat.class);
} catch (HttpClientErrorException.UnprocessableEntity e) {
// Le retour du navigateur et le webhook peuvent capturer en même temps : le second constate
// ORDER_ALREADY_CAPTURED. Ce n'est pas une erreur, on relit simplement la commande.
if (e.getResponseBodyAsString().contains("ORDER_ALREADY_CAPTURED")) {
return CaptureResultat.depuis(lireCommande(orderId));
}
throw e;
}
} Un statut PENDING signifie que PayPal examine la transaction : le paiement n’est pas encore acquis, et l’événement final arrivera plus tard. Le client voit une page « paiement en cours », pas une confirmation.
Le filet de sécurité du webhook
Si le client ferme son navigateur juste après l’approbation, la page de retour n’est jamais appelée. Le webhook CHECKOUT.ORDER.APPROVED rattrape ce cas :
@Component("CHECKOUT.ORDER.APPROVED")
public class CommandeApprouveeHandler implements EvenementHandler {
private final PayPalClient paypal;
@Override
public void traiter(JsonNode evt) {
// Le client a approuvé mais n'est peut-être jamais revenu sur la page de retour : on capture ici.
// L'écriture dans Salesforce suivra l'événement PAYMENT.CAPTURE.COMPLETED.
paypal.capturer(evt.at("/resource/id").asText());
}
} Aucune des deux voies n’écrit directement le statut final dans Salesforce : c’est l’événement de capture, relu chez PayPal, qui fait foi. Il n’y a ainsi qu’un seul chemin vers le statut « Capturé ».
Bonnes pratiques
- Sandbox d’abord : PayPal fournit des comptes acheteurs fictifs pour jouer tout le parcours, refus et annulation compris.
- Lien à usage unique : le lien d’approbation n’est valable que pour sa commande ; le stocker dans Salesforce permet de le renvoyer sans en créer un autre.
- Pas de confirmation sur la page de retour seule : elle peut être appelée à la main. L’état fiable est celui de la capture.
- Journaliser l’identifiant Salesforce, l’identifiant de commande et la clé d’idempotence à chaque étape : c’est le fil qui relie les trois systèmes lors d’une réclamation.
Le paiement est encaissé. Reste la vie après le paiement : remboursements, litiges et rapprochement.