Skip to content

fix: Fix Stripe filtering of payment_intent.succeeded webhook messages

stephen requested to merge fix-stripe-webhook-filtering into main

When a Stripe transaction is made, a payment_intent.succeded or payment_intent.failed message is generated. However, when a recurring Stripe transaction is made, additional webhook messages are generated that we would prefer to work with, as they contain subscription-related data we care about. Conversely, one-time donations only generate payment_intent.succeded or payment_intent.failed messages - there are no additional messages which contain sufficient data for our purposes.

Therefore, we are in the position of having to inspect incoming payment_intent.succeded and payment_intent.failed webhook messages, and determine from their contents alone whether they belong to a one-time donation - in which case we'd like to process it - or a recurring donation series - in which case we'd like to ignore it.

Previously, this codebase used the data.object.description field for this purpose, since in testing (both here and through Stripe Dashboard), when a payment_intent webhook arrived, this field held a text string containing the word "Subscription" in plaintext. This seemed a little suspicious, but was 100% reliable during development, and all subsequent tests conducted against this endpoint worked perfectly in the run-up to launch.

(ominous pause)

This brings us to this commit, which addresses the problem described here: In production, this field simply contains a different format of string altogether, and one which does not contain the word "Subscription," and so every single recurring Stripe donation is being counted twice, once as a falsified one-time donation with no associated form data.

Because the Stripe documentation was so unclear on what the intended method of performing this differentiation was, I called Stripe support directly to ask them. They agreed that the documentation was unclear and clarified that the proper field to perform this differentiation is data.object.invoice - not all that far from where we were looking before, in the end.

As I mention in the now-lengthy code comments, one-time transactions created via Payment Intent, as ours are, do not generate an invoice ID. Invoices are, in Stripe, objects specifically created in relation to a subscription, and to that end, one can perform a lookup on an invoice ID with the Stripe SDK and retrieve specific information about which portion of a subscription lifecycle a particular invoice belongs to. Since "subscription" and "invoice" are such tightly-bound concepts, it is, according to Stripe's support team, safe to probe for an invoice ID at data.object.invoice and use its existence as a way to detect a subscription-related payment_intent.

After reviewing both webhook messages generated during testing, as well as webhook messages received in production, this seems to be the case - one-time payments' payment_intent.succeeded webhook messages arrive with data.object.invoice present but set to null, whereas subscription payments' payment_intent.succeeded webhook messages arrive with data.object.invoice bearing a string starting with in_.

Additionally, in reviewing the Stripe webhook overall, it is worth noting (even if in hindsight) that the two webhook message types we definitively associate with subscriptions are invoice.payment_succeeded and invoice.payment_failed.

See civicrm#145 for more.

Edited by anarcat

Merge request reports