fix: Fix Stripe filtering of payment_intent.succeeded webhook messages
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.