Skip to content

feat: Validates IPN messages via Paypal and processes IPN data

stephen requested to merge process-legacy-ipns into main

The overview of MR !156 (merged) describes the history of the Paypal IPN message and details our need to handle those messages and pass along the subscription information they detail to CiviCRM, as we do with webhook messages from both Paypal and Stripe. This MR seeks to fulfill that need by validating these messages against Paypal's own IPN-validation endpoints, then processing the messages' data, just as we do with webhook message data.


An IPN validation method, tordonate.paypal.controller.validate_ipn(), receives the content of POST messages sent to /paypal-ipn/, formats it in the specific manner which Paypal's IPN validation specs require, and POSTs back to an IPN-validation Paypal URL.

Paypal will respond to these POSTs with text reading either "VERIFIED" or "INVALID," and validate_ipn() returns this information as a boolean back to the view. Based on these results, tordonate.paypal.views.ipn then invokes the method process_ipn(), which as of this commit is merely a stub.

!156 (merged) mistakenly implemented the IPN message parser as though the IPN data was being passed along as a URL parameter; it is in fact delivered as a POST message body. Due to Paypal being persnickety about receiving IPN messages back in exactly the same format, we now handle that POST body as a bytestring directly.

PayPal's own requirements for handling IPNs as detailed in their legacy documentation is no longer completely correct, and several tools mentioned optimistically in !156 (merged) no longer function as originally intended. This scenario is discussed in a large comment block within validate_ipn() under the presumption that anyone nosing around in this part of the codebase will want all the context they can get.


IPN messages, once validated, contain all of the same information we take from webhook messages to construct outgoing Resque messages to CiviCRM. This commit adds the tordonate.paypal.controller method process_ipn(), which is invoked by tordonate.paypal.views.ipn() after successfully validating an IPN message.

process_ipn(), like validate_ipn() before it, accepts a single paramater - the body of the original POST request, in bytes. Since all IPN messages are strings containing data expressed in key-value pairs, formatted as a URL querystring, process_ipn() converts those bytes into a dict, and from it, constructs the data necessary to call tordonate.civicrm.repository.donation_exists() and tordonate.civicrm.repository.report_donation(), just as we would with data pulled from a webhook message. We similarly use this data to manage the relevant Prometheus vendor transaction datapoint.

The only tricky part of this exercise is (unsurprisingly) the timestamp; IPN messages use a bespoke format for their timestamps, and unlike all timestamps used by webhook messages, they are set to US Pacific time; a certain amount of conversion is necessary to have them match the timestamps generated from webhook messages.


Comprehensive testing for IPN validation and IPN processing has additionally been added, as well as testing against general interaction with the /paypal-ipn URL endpoint. Because IPN message test data is expressed in pure text rather than in a data format, the /json subfolder of the Paypal test files folder has been renamed /data, and IPN message test data has been stored as .txt files in /data. All relevant Paypal tests have been updated to reflect this change.

Merge request reports

Loading