Tutoriel · PayPal · Salesforce · Java
Remboursements, litiges et rapprochement PayPal dans Salesforce
Après le paiement : remboursement total ou partiel déclenché depuis Salesforce, litiges transformés en demandes avec échéance, et contrôle nocturne des transactions.
La vie après le paiement
Un paiement encaissé, comme dans l’article précédent, n’est pas une affaire close. Chez Acme Assurances, entreprise fictive, trois situations reviennent :
- un conseiller rembourse tout ou partie d’un paiement, après une résiliation ou un geste commercial ;
- un client ouvre un litige chez PayPal, avec un délai de réponse à tenir ;
- un événement s’est perdu malgré les précautions, et Salesforce ne reflète plus PayPal.
Les trois s’appuient sur les briques déjà construites : le client Salesforce idempotent et la file d’événements.
Rembourser depuis Salesforce
Une demande de remboursement est un enregistrement à part entière : elle a un auteur, un motif et une validation éventuelle, que Salesforce sait gérer (processus d’approbation, règles de validation).
Remboursement__c (une demande de remboursement, créée par un conseiller)
Paiement__c Master-Detail(Paiement__c)
Montant__c Currency
Motif__c Picklist Resiliation, Geste_commercial, Erreur
Statut__c Picklist Demande, Envoye, Rembourse, Refuse
PayPal_Refund_Id__c Text(36) External ID, Unique Une fois la demande approuvée, une action Apex appelle le middleware avec son seul identifiant, sur le même modèle que la demande de paiement. Le middleware relit tout, vérifie le montant restant et appelle l’API PayPal de remboursement sur la capture :
@PostMapping("/remboursements/{remboursementId}/paypal")
public Remboursement rembourser(@PathVariable @Pattern(regexp = "^[a-zA-Z0-9]{18}$") String remboursementId) {
// Comme pour le paiement : tout est relu dans Salesforce, rien n'est accepté de l'appelant.
RemboursementLu r = salesforce.lireRemboursement(remboursementId);
PaiementLu p = r.paiement();
BigDecimal restant = p.montant().subtract(p.montantRembourse());
if (!"Demande".equals(r.statut()) || r.montant().compareTo(restant) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Remboursement impossible : " + restant + " restant");
}
RefundPayPal refund = http.post()
.uri("/v2/payments/captures/{id}/refund", p.captureId())
// Une demande Salesforce = un remboursement PayPal, même si l'appel est rejoué.
.header("PayPal-Request-Id", "remboursement-" + remboursementId)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of(
"amount", Map.of("currency_code", p.devise(), "value", r.montant().toPlainString()),
"invoice_id", p.reference() + "-R-" + remboursementId,
"note_to_payer", "Acme Assurances : " + r.motifLibelle()))
.retrieve()
.body(RefundPayPal.class);
salesforce.mettreAJour("Remboursement__c", remboursementId, Map.of(
"PayPal_Refund_Id__c", refund.id(),
"Statut__c", "COMPLETED".equals(refund.status()) ? "Rembourse" : "Envoye"));
return new Remboursement(refund.id(), refund.status());
} Sans montant, PayPal rembourse la totalité ; avec un montant, le remboursement est partiel, et plusieurs remboursements partiels peuvent se succéder jusqu’au montant capturé. La clé PayPal-Request-Id dérivée de l’identifiant Salesforce garantit qu’un appel rejoué ne rembourse pas deux fois.
Suivre le remboursement
Un remboursement peut rester en attente, par exemple quand le solde du compte PayPal est insuffisant. L’état final arrive par l’événement PAYMENT.CAPTURE.REFUNDED, traité comme les autres : on relit le remboursement et la capture, puis on met Salesforce à jour.
@Component("PAYMENT.CAPTURE.REFUNDED")
public class CaptureRembourseeHandler implements EvenementHandler {
@Override
public void traiter(JsonNode evt) {
String refundId = evt.at("/resource/id").asText();
RefundPayPal refund = paypal.lireRemboursement(refundId);
Capture capture = paypal.capture(refund.captureId()); // lien « up » du remboursement
// Montant total remboursé sur la capture, calculé par PayPal : pas d'addition côté middleware.
salesforce.upsertPaiementParCommande(capture.orderId(), new PaiementSf(
StatutPaiement.depuis(capture.status()), // REFUNDED ou PARTIALLY_REFUNDED
null, null, capture.totalRembourse()));
salesforce.upsert("Remboursement__c", "PayPal_Refund_Id__c", refundId, Map.of(
"Statut__c", "COMPLETED".equals(refund.status()) ? "Rembourse" : "Envoye"));
}
} Le montant total remboursé vient de PayPal, pas d’un cumul calculé par le middleware : un événement rejoué ne le compte pas deux fois. Le DTO PaiementSf gagne pour cela un champ Montant_Rembourse__c.
Les litiges deviennent des demandes
Un litige PayPal (article non reçu, transaction non autorisée) a une échéance : sans réponse du vendeur à temps, PayPal tranche en faveur du client. Dans Salesforce, c’est exactement le rôle d’une demande (Case) : une file, un responsable, un délai.
@Component("CUSTOMER.DISPUTE.CREATED")
public class LitigeOuvertHandler implements EvenementHandler {
@Override
public void traiter(JsonNode evt) {
Litige l = paypal.lireLitige(evt.at("/resource/dispute_id").asText());
Map<String, Object> demande = new LinkedHashMap<>();
demande.put("Subject", "Litige PayPal " + l.id() + " : " + l.motif()); // ITEM_NOT_RECEIVED…
demande.put("Origin", "PayPal");
demande.put("Priority", "High");
demande.put("Echeance_Reponse__c", l.echeanceVendeur()); // seller_response_due_date
demande.put("Montant_Litige__c", l.montant());
// Relation par External ID : Salesforce retrouve le paiement à partir de l'identifiant de capture.
demande.put("Paiement__r", Map.of("PayPal_Capture_Id__c", l.captureId()));
salesforce.upsert("Case", "PayPal_Dispute_Id__c", l.id(), demande);
salesforce.upsert("Paiement__c", "PayPal_Capture_Id__c", l.captureId(), Map.of("Statut__c", "Litige"));
}
} La demande est rattachée au paiement par une relation par External ID (Paiement__r avec l’identifiant de capture) : pas besoin de rechercher l’identifiant Salesforce au préalable. Les événements CUSTOMER.DISPUTE.UPDATED et CUSTOMER.DISPUTE.RESOLVED mettent à jour la même demande, retrouvée par PayPal_Dispute_Id__c.
Une règle d’escalade Salesforce sur Echeance_Reponse__c alerte le responsable quelques jours avant la date limite. La réponse elle-même (preuves, message) peut rester dans le tableau de bord PayPal, ou passer par l’API des litiges si le volume le justifie.
Le rapprochement nocturne
Webhooks vérifiés, file, reprises : le dispositif est solide, mais pas infaillible. Une mauvaise configuration du webhook ou une coupure prolongée peuvent faire perdre des événements. Le rapprochement compare chaque nuit les transactions PayPal à Salesforce, indépendamment des webhooks.
L’API Transaction Search liste les transactions d’une période, 31 jours au plus par requête, avec un délai de publication de quelques heures. Elle doit être activée pour l’application dans le tableau de bord développeur PayPal.
@Scheduled(cron = "0 30 4 * * *", zone = "Europe/Paris")
public void rapprocherLaVeille() {
// Fenêtre décalée : PayPal publie les transactions avec quelques heures de délai.
Instant fin = Instant.now().minus(Duration.ofHours(6));
Instant debut = fin.minus(Duration.ofDays(1));
int page = 1, pages;
do {
int numero = page; // une lambda ne capture qu'une variable effectivement finale
RecherchePayPal r = http.get().uri(b -> b.path("/v1/reporting/transactions")
.queryParam("start_date", debut.toString())
.queryParam("end_date", fin.toString())
.queryParam("fields", "transaction_info")
.queryParam("page_size", 500)
.queryParam("page", numero)
.build())
.retrieve()
.body(RecherchePayPal.class);
pages = r.total_pages();
// Un aller-retour Salesforce par paquet de 200 identifiants (requête SOQL avec IN).
for (List<TransactionPayPal> lot : Lists.partition(r.captures(), 200)) {
Map<String, PaiementLu> sf = salesforce.paiementsParCapture(lot.stream().map(TransactionPayPal::id).toList());
lot.forEach(t -> comparer(t, sf.get(t.id())));
}
page++;
} while (page <= pages);
} Le champ custom_field de chaque transaction contient le custom_id donné à la création de la commande, c’est-à-dire l’identifiant Salesforce : même un paiement dont Salesforce ignore tout se rattache à son enregistrement.
Traiter les écarts
Trois cas, trois réponses :
private void comparer(TransactionPayPal t, PaiementLu p) {
if (p == null) {
// Capture inconnue de Salesforce : un événement a été perdu. On rejoue le traitement habituel.
captureHandler.synchroniser(t.id());
ecarts.signaler("CAPTURE_MANQUANTE", t.id(), "rattrapée");
} else if (t.montant().compareTo(p.montant()) != 0) {
// Montant différent : jamais de correction automatique, une personne doit comprendre pourquoi.
ecarts.signaler("MONTANT_DIFFERENT", t.id(), t.montant() + " PayPal / " + p.montant() + " Salesforce");
} else if (!StatutPaiement.compatible(t.statut(), p.statut())) {
captureHandler.synchroniser(t.id());
ecarts.signaler("STATUT_DIFFERENT", t.id(), t.statut() + " / " + p.statut());
}
} - Capture absente ou statut différent : le traitement normal est rejoué. Grâce à l’idempotence, c’est sans risque.
- Montant différent : jamais corrigé automatiquement. L’écart part dans un rapport, ou crée une demande pour l’équipe financière.
- Aucun écart : le compte rendu le dit aussi. Un rapprochement silencieux ne se distingue pas d’un rapprochement qui ne tourne plus.
Le même principe vaut pour Stripe, avec d’autres API, dans l’article sur le rapprochement Stripe ↔ Salesforce.
Bilan de la série
Le middleware Spring Boot tient en quelques composants : deux fournisseurs de jeton, un client Salesforce idempotent avec reprises, un point de réception des webhooks vérifiés, une file en base et ses handlers, deux points d’accès appelés par Salesforce et une tâche de rapprochement. Chaque système reste maître de ses données : PayPal du paiement, Salesforce de la relation, le middleware de rien d’autre que la bonne transmission entre les deux.