Méthode · Stripe · Salesforce
Rapprocher Stripe et Salesforce : rattraper les écarts
Webhooks manqués, désactivés ou en échec : rejouer les événements récents, comparer chaque nuit factures Stripe et paiements Salesforce, et corriger sans doublon.
D’où viennent les écarts
Même avec des webhooks dédoublonnés et une file qui réessaie, Stripe et Salesforce finissent par diverger un jour :
- le middleware est resté indisponible plus longtemps que la fenêtre de renvoi de Stripe, ou l’endpoint a été désactivé après trop d’échecs ;
- un événement a été traité avec un bogue de mapping, corrigé depuis ;
- un message est resté dans le transport d’échec sans que personne ne le rejoue ;
- quelqu’un a modifié à la main un enregistrement Salesforce censé être en lecture seule.
Pour une donnée financière, « presque toujours juste » ne suffit pas. Deux filets complètent le flux temps réel du middleware : rejouer les événements récents, et comparer les objets eux-mêmes.
Rejouer les événements récents
L’API Stripe liste les événements des 30 derniers jours. Une commande horaire parcourt ceux des derniers jours et publie ceux que la table locale ne connaît pas :
#[AsCommand(name: 'app:stripe:replay-events', description: 'Rejoue les événements Stripe absents de la table locale')]
final class ReplayStripeEventsCommand extends Command
{
private const TYPES = [
'checkout.session.completed', 'customer.subscription.created', 'customer.subscription.updated',
'customer.subscription.deleted', 'invoice.paid', 'invoice.payment_failed', 'invoice.finalized',
];
protected function execute(InputInterface $input, OutputInterface $output): int
{
$count = 0;
// Stripe conserve les événements 30 jours : au-delà, seule la comparaison des objets permet de rattraper.
$events = $this->stripe->events->all([
'types' => self::TYPES,
'created' => ['gte' => strtotime('-3 days')],
'limit' => 100,
]);
foreach ($events->autoPagingIterator() as $event) {
// Même point d'entrée que le webhook : dédoublonnage et file identiques.
$this->events->receiveOnce($event->id, $event->type, function () use ($event, &$count): void {
$this->bus->dispatch(new StripeEventReceived($event->id, $event->type, $event->data->object->id));
++$count;
});
}
$output->writeln("$count événement(s) rattrapé(s)");
return Command::SUCCESS;
}
} Comme elle passe par le même dédoublonnage que le webhook, la commande peut tourner aussi souvent que voulu : sur une intégration saine, elle ne trouve rien.
Comparer chaque nuit
Rejouer ne rattrape pas un bogue de traitement ni une modification manuelle : l’événement a bien été traité, mais le résultat est faux. Il faut comparer les objets : chaque nuit, les factures Stripe de la veille face aux paiements Salesforce de la même période.
/** @return array<string, array{status: string, paid: int}> factures Stripe de la période, par identifiant */
private function stripeInvoices(int $from, int $to): array
{
$invoices = [];
$page = $this->stripe->invoices->all(['created' => ['gte' => $from, 'lt' => $to], 'limit' => 100]);
foreach ($page->autoPagingIterator() as $invoice) {
if ($invoice->parent?->subscription_details?->subscription === null) {
continue; // hors abonnement : non synchronisé, donc hors rapprochement
}
$invoices[$invoice->id] = ['status' => $invoice->status, 'paid' => $invoice->amount_paid];
}
return $invoices;
} Côté Salesforce, une requête suffit pour quelques milliers de lignes par jour ; pour un contrôle complet sur des mois, un job de requête de la Bulk API évite de consommer des centaines d’appels.
SELECT Stripe_Invoice_Id__c, Statut__c, Montant_Paye__c
FROM Paiement__c
WHERE Date_Facture__c >= :from AND Date_Facture__c < :to public function reconcile(\DateTimeImmutable $day): array
{
$from = $day->getTimestamp();
$to = $day->modify('+1 day')->getTimestamp();
$stripe = $this->stripeInvoices($from, $to);
$salesforce = $this->salesforceInvoices($day); // même forme, montants reconvertis en centimes
$gaps = [];
foreach ($stripe as $id => $s) {
$sf = $salesforce[$id] ?? null;
$gaps[$id] = match (true) {
$sf === null => 'absente de Salesforce',
self::STATUTS[$s['status']] !== $sf['status'] => "statut {$s['status']} ≠ {$sf['status']}",
$s['paid'] !== $sf['paid'] => "montant {$s['paid']} ≠ {$sf['paid']}",
default => null,
};
}
foreach (array_diff_key($salesforce, $stripe) as $id => $sf) {
$gaps[$id] = 'inconnue de Stripe'; // jamais corrigé automatiquement : à analyser
}
return array_filter($gaps);
} La comparaison se fait en centimes des deux côtés : comparer des décimaux convertis expose à des écarts d’arrondi qui n’en sont pas.
Corriger sans doublon
Stripe étant maître du paiement, la correction va toujours dans le même sens : relire l’objet chez Stripe et réécrire son état dans Salesforce, avec le service déjà utilisé par les webhooks. L’upsert sur l’identifiant de facture rend l’opération sûre, même répétée.
foreach ($gaps as $invoiceId => $reason) {
if ($reason === 'inconnue de Stripe') {
$this->logger->critical('Paiement Salesforce sans facture Stripe : {id}', ['id' => $invoiceId]);
continue;
}
// Même service que le webhook : relecture chez Stripe, puis upsert de l'état complet.
$this->invoices->sync($invoiceId);
$this->logger->warning('Écart corrigé sur {id} : {reason}', ['id' => $invoiceId, 'reason' => $reason]);
} Le cas inverse, un paiement Salesforce sans facture Stripe, ne se corrige pas automatiquement : supprimer un enregistrement financier mérite une analyse humaine.
Le composant Scheduler de Symfony planifie les deux commandes sans crontab :
#[AsSchedule('stripe')]
final class StripeSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::every('1 hour', new RunCommandMessage('app:stripe:replay-events')),
RecurringMessage::cron('30 3 * * *', new RunCommandMessage('app:stripe:reconcile --day=yesterday')),
);
}
}Suivre la santé de l’intégration
Quelques indicateurs suffisent à savoir si le flux temps réel fonctionne, sans attendre le rapprochement :
- Événements en attente depuis plus de quelques minutes ;
- Taille du transport d’échec de la file
stripe; - Événements rattrapés par la commande horaire : non nul, il signale des webhooks perdus ;
- Écarts corrigés par le rapprochement nocturne : non nul, il signale un défaut de traitement.
-- Événements reçus mais toujours pas traités après 15 minutes : la file est bloquée ou en échec.
SELECT type, COUNT(*) AS en_attente
FROM stripe_event
WHERE processed_at IS NULL AND received_at < NOW() - INTERVAL 15 MINUTE
GROUP BY type; L’objectif n’est pas zéro écart corrigé, mais zéro écart inexpliqué : chaque correction doit pouvoir se rattacher à un incident connu.
Bonnes pratiques
- Fenêtre glissante : comparer aussi les jours précédents, car une facture peut changer de statut plusieurs jours après sa création (paiement tardif, remboursement).
- Rapport lisible : un récapitulatif quotidien envoyé à l’équipe, avec les écarts par cause.
- Champs verrouillés : le meilleur rapprochement est celui qui ne trouve rien ; interdire la modification manuelle des champs Stripe dans Salesforce supprime une cause entière.
- Exports comptables : la comptabilité s’appuie sur les rapports Stripe, pas sur la copie Salesforce, qui sert au suivi de la relation client.