Architecture · PayPal · Salesforce · Java
PayPal et Salesforce : architecture d’un middleware Spring Boot
PayPal maître du paiement, Salesforce de la relation client : objet Paiement, identifiants croisés, flux entrants et sortants, jeton PayPal mis en cache et configuration typée.
Pourquoi un middleware
Salesforce sait appeler une API externe en Apex, et PayPal sait notifier une URL. On pourrait donc relier les deux directement. En pratique, un service intermédiaire règle plusieurs problèmes d’un coup :
- les webhooks PayPal arrivent à toute heure, parfois en double ou dans le désordre : il faut les vérifier, les stocker et les rejouer, ce qu’Apex fait mal ;
- les secrets PayPal restent dans un seul service, et non dans l’org ;
- les limites de Salesforce (appels d’API, durée des callouts) sont absorbées par une file et des reprises ;
- le jour où un autre canal (site, application mobile) encaisse aussi, il passe par le même service.
Cette série construit ce middleware en Java 21 avec Spring Boot 3, pour Acme Assurances, une entreprise fictive qui fait payer certaines prestations par PayPal. L’équivalent pour Stripe en Symfony commence par Stripe et Salesforce : architecture d’un middleware de paiement.
Qui est maître de quoi
La règle qui évite la plupart des incohérences : chaque donnée a un seul système maître.
- PayPal est maître du paiement : commande, approbation, capture, remboursement, litige. Salesforce n’en garde qu’un reflet.
- Salesforce est maître de la relation : qui est le client, ce qu’il doit, quel conseiller suit le dossier.
- Le middleware n’est maître de rien : il traduit, transporte, rejoue. Sa base ne contient que des données techniques (événements reçus, état des traitements).
Conséquence directe : un conseiller ne modifie jamais à la main le statut d’un paiement dans Salesforce. Le statut change parce que PayPal a notifié un changement.
Le modèle de données
Côté Salesforce, un objet personnalisé Paiement__c représente un paiement demandé. Deux identifiants PayPal y sont stockés en External ID uniques :
Paiement__c (un paiement demandé à un client)
Compte__c Lookup(Account)
Montant__c Currency
Devise__c Picklist EUR
Statut__c Picklist A_payer, Approuve, Capture, Refuse, Rembourse, Litige
Lien_Paiement__c URL lien d'approbation PayPal envoyé au client
PayPal_Order_Id__c Text(36) External ID, Unique : la commande PayPal
PayPal_Capture_Id__c Text(36) External ID, Unique : l'encaissement effectif
Montant_Rembourse__c Currency Pourquoi deux identifiants ? Chez PayPal, une commande (order) est l’intention de payer, approuvée par le client ; la capture est l’encaissement effectif. Les webhooks de paiement et les remboursements portent sur la capture, la création porte sur la commande. Les deux External ID permettent un upsert idempotent quel que soit l’événement reçu.
Dans l’autre sens, la commande PayPal transporte l’identifiant Salesforce dans son champ custom_id : un événement qui arrive avant que Salesforce connaisse l’identifiant PayPal retrouve quand même son enregistrement.
Les flux
- Salesforce → middleware → PayPal : un conseiller demande un paiement ; le middleware crée la commande PayPal et renvoie un lien d’approbation (article 4).
- PayPal → middleware : approbation, capture, refus, remboursement, litige arrivent en webhooks, vérifiés puis mis en file (article 3).
- Middleware → Salesforce : chaque événement devient un upsert sur
Paiement__c, ou une demande (Case) pour un litige (article 2). - Contrôle nocturne : le middleware compare les transactions PayPal et les paiements Salesforce pour rattraper un événement perdu (article 5).
Le projet Spring Boot
Un projet généré par Spring Initializr suffit, avec le Web, JPA pour la file d’événements, la validation et Resilience4j pour les reprises :
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies> La configuration ne contient aucun secret : seulement des références à des variables d’environnement, alimentées par le coffre-fort de la plateforme d’hébergement.
# application.yml : aucun secret ici, uniquement des références à l'environnement.
paypal:
base-url: https://api-m.sandbox.paypal.com # https://api-m.paypal.com en production
client-id: ${PAYPAL_CLIENT_ID}
client-secret: ${PAYPAL_CLIENT_SECRET}
webhook-id: ${PAYPAL_WEBHOOK_ID}
salesforce:
instance-url: https://acme-demo.my.salesforce.com
api-version: v62.0
client-id: ${SF_CLIENT_ID}
client-secret: ${SF_CLIENT_SECRET} Chaque bloc est lié à un record validé au démarrage : une variable manquante empêche l’application de démarrer, plutôt que de faire échouer le premier paiement.
@ConfigurationProperties(prefix = "paypal")
@Validated
public record PayPalProperties(
@NotNull URI baseUrl,
@NotBlank String clientId,
@NotBlank String clientSecret,
@NotBlank String webhookId) {
} On l’active avec @ConfigurationPropertiesScan sur la classe principale.
Le jeton PayPal
L’API REST de PayPal s’authentifie en OAuth 2.0 client credentials : l’identifiant et le secret de l’application, en authentification Basic, contre un jeton d’accès valable plusieurs heures. Demander un jeton à chaque appel est inutile et lent : on le garde en mémoire jusqu’à peu avant son expiration.
@Component
public class PayPalTokenProvider {
private final RestClient http;
private final PayPalProperties props;
private volatile Token cache;
private record Token(String value, Instant expiresAt) {}
private record TokenResponse(String access_token, long expires_in) {}
public PayPalTokenProvider(RestClient.Builder builder, PayPalProperties props) {
this.props = props;
this.http = builder.baseUrl(props.baseUrl().toString()).build();
}
public String token() {
Token t = cache;
// Marge de 5 minutes : un jeton qui expire pendant l'appel provoquerait un 401 inutile.
if (t == null || Instant.now().isAfter(t.expiresAt().minusSeconds(300))) {
synchronized (this) {
if (cache == t) {
cache = fetch();
}
t = cache;
}
}
return t.value();
}
private Token fetch() {
TokenResponse r = http.post()
.uri("/v1/oauth2/token")
.headers(h -> h.setBasicAuth(props.clientId(), props.clientSecret()))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("grant_type=client_credentials")
.retrieve()
.body(TokenResponse.class);
return new Token(r.access_token(), Instant.now().plusSeconds(r.expires_in()));
}
} Le verrou ne protège que le renouvellement : les appels concurrents lisent le jeton en cache sans attendre. Avec plusieurs instances du middleware, chacune obtient son propre jeton, ce que PayPal accepte.
Ce qui ne doit jamais entrer dans Salesforce
- Aucune donnée de carte : le client saisit ses moyens de paiement sur les pages PayPal. Ni le middleware ni Salesforce n’en voient, ce qui les garde hors du périmètre PCI DSS.
- Aucun secret PayPal dans l’org : Salesforce appelle le middleware, jamais PayPal directement.
- Pas de données PayPal superflues : l’adresse ou l’e-mail PayPal du payeur ne sont copiés que si un usage métier le justifie, conformément au RGPD.
Prochaine étape : le client Java qui écrit dans Salesforce, robuste face aux limites et aux pannes.