dev/core#1558 Data conversion for non-standard setting.
[civicrm-core.git] / tests / phpunit / CRM / Core / Payment / PayPalIPNTest.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * Class CRM_Core_Payment_PayPalProIPNTest
14 * @group headless
15 */
16 class CRM_Core_Payment_PayPalIPNTest extends CiviUnitTestCase {
17 protected $_contributionID;
18 protected $_invoiceID = 'c2r9c15f7be20b4f3fef1f77e4c37424';
19 protected $_financialTypeID = 1;
20 protected $_contactID;
21 protected $_contributionRecurID;
22 protected $_contributionPageID;
23 protected $_paymentProcessorID;
24 protected $_customFieldID;
25 /**
26 * IDs of entities created to support the tests.
27 *
28 * @var array
29 */
30 protected $ids = [];
31
32 /**
33 * Set up function.
34 */
35 public function setUp() {
36 parent::setUp();
37 $this->_paymentProcessorID = $this->paymentProcessorCreate(['is_test' => 0, 'payment_processor_type_id' => 'PayPal_Standard']);
38 $this->_contactID = $this->individualCreate();
39 $contributionPage = $this->callAPISuccess('contribution_page', 'create', [
40 'title' => "Test Contribution Page",
41 'financial_type_id' => $this->_financialTypeID,
42 'currency' => 'USD',
43 'payment_processor' => $this->_paymentProcessorID,
44 ]);
45 $this->_contributionPageID = $contributionPage['id'];
46 }
47
48 /**
49 * Tear down function.
50 */
51 public function tearDown() {
52 $this->quickCleanUpFinancialEntities();
53 }
54
55 /**
56 * Test IPN response updates contribution and invoice is attached in mail reciept
57 *
58 * The scenario is that a pending contribution exists and the IPN call will update it to completed.
59 * And also if Tax and Invoicing is enabled, this unit test ensure that invoice pdf is attached with email recipet
60 *
61 * @throws \CRM_Core_Exception
62 */
63 public function testInvoiceSentOnIPNPaymentSuccess() {
64 $this->enableTaxAndInvoicing();
65
66 $pendingStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
67 $completedStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
68 $params = [
69 'payment_processor_id' => $this->_paymentProcessorID,
70 'contact_id' => $this->_contactID,
71 'trxn_id' => NULL,
72 'invoice_id' => $this->_invoiceID,
73 'contribution_status_id' => $pendingStatusID,
74 'is_email_receipt' => TRUE,
75 ];
76 $this->_contributionID = $this->contributionCreate($params);
77 $contribution = $this->callAPISuccess('contribution', 'get', ['id' => $this->_contributionID, 'sequential' => 1]);
78 // assert that contribution created before handling payment via paypal standard has no transaction id set and pending status
79 $this->assertEquals(NULL, $contribution['values'][0]['trxn_id']);
80 $this->assertEquals($pendingStatusID, $contribution['values'][0]['contribution_status_id']);
81
82 global $_REQUEST;
83 $_REQUEST = ['q' => CRM_Utils_System::url('civicrm/payment/ipn/' . $this->_paymentProcessorID)] + $this->getPaypalTransaction();
84
85 $mut = new CiviMailUtils($this, TRUE);
86 $paymentProcesors = $this->callAPISuccessGetSingle('PaymentProcessor', ['id' => $this->_paymentProcessorID]);
87 $payment = Civi\Payment\System::singleton()->getByProcessor($paymentProcesors);
88 $payment->handlePaymentNotification();
89
90 // Check if invoice pdf is attached with contribution mail reciept
91 $mut->checkMailLog([
92 'Content-Transfer-Encoding: base64',
93 'Content-Type: application/pdf',
94 'filename=Invoice.pdf',
95 ]);
96 $mut->stop();
97
98 $contribution = $this->callAPISuccess('contribution', 'get', ['id' => $this->_contributionID, 'sequential' => 1]);
99 // assert that contribution is completed after getting response from paypal standard which has transaction id set and completed status
100 $this->assertEquals($_REQUEST['txn_id'], $contribution['values'][0]['trxn_id']);
101 $this->assertEquals($completedStatusID, $contribution['values'][0]['contribution_status_id']);
102 }
103
104 /**
105 * Test IPN response updates contribution_recur & contribution for first & second contribution.
106 *
107 * The scenario is that a pending contribution exists and the first call will update it to completed.
108 * The second will create a new contribution.
109 */
110 public function testIPNPaymentRecurSuccess() {
111 $this->setupRecurringPaymentProcessorTransaction([], ['total_amount' => '15.00']);
112 $paypalIPN = new CRM_Core_Payment_PayPalIPN($this->getPaypalRecurTransaction());
113 $paypalIPN->main();
114 $contribution1 = $this->callAPISuccess('contribution', 'getsingle', ['id' => $this->_contributionID]);
115 $this->assertEquals(1, $contribution1['contribution_status_id']);
116 $this->assertEquals('8XA571746W2698126', $contribution1['trxn_id']);
117 // source gets set by processor
118 $this->assertTrue(substr($contribution1['contribution_source'], 0, 20) == "Online Contribution:");
119 $contributionRecur = $this->callAPISuccess('contribution_recur', 'getsingle', ['id' => $this->_contributionRecurID]);
120 $this->assertEquals(5, $contributionRecur['contribution_status_id']);
121 $paypalIPN = new CRM_Core_Payment_PayPalIPN($this->getPaypalRecurSubsequentTransaction());
122 $paypalIPN->main();
123 $contributions = $this->callAPISuccess('contribution', 'get', [
124 'contribution_recur_id' => $this->_contributionRecurID,
125 'sequential' => 1,
126 ]);
127 $this->assertEquals(2, $contributions['count']);
128 $contribution2 = $contributions['values'][1];
129 $this->assertEquals('secondone', $contribution2['trxn_id']);
130 $paramsThatShouldMatch = [
131 'total_amount',
132 'net_amount',
133 'fee_amount',
134 'payment_instrument',
135 'payment_instrument_id',
136 'financial_type',
137 'financial_type_id',
138 ];
139 foreach ($paramsThatShouldMatch as $match) {
140 $this->assertEquals($contribution1[$match], $contribution2[$match]);
141 }
142 }
143
144 /**
145 * Test IPN response updates contribution_recur & contribution for first & second contribution.
146 *
147 * @throws \CRM_Core_Exception
148 * @throws \CiviCRM_API3_Exception
149 */
150 public function testIPNPaymentMembershipRecurSuccess() {
151 $durationUnit = 'year';
152 $this->setupMembershipRecurringPaymentProcessorTransaction(['duration_unit' => $durationUnit, 'frequency_unit' => $durationUnit]);
153 $this->callAPISuccessGetSingle('membership_payment', []);
154 $paypalIPN = new CRM_Core_Payment_PayPalIPN($this->getPaypalRecurTransaction());
155 $paypalIPN->main();
156 $contribution = $this->callAPISuccess('contribution', 'getsingle', ['id' => $this->_contributionID]);
157 $membershipEndDate = $this->callAPISuccessGetValue('membership', ['return' => 'end_date']);
158 $this->assertEquals(1, $contribution['contribution_status_id']);
159 $this->assertEquals('8XA571746W2698126', $contribution['trxn_id']);
160 // source gets set by processor
161 $this->assertTrue(substr($contribution['contribution_source'], 0, 20) === "Online Contribution:");
162 $contributionRecur = $this->callAPISuccess('contribution_recur', 'getsingle', ['id' => $this->_contributionRecurID]);
163 $this->assertEquals(5, $contributionRecur['contribution_status_id']);
164 $paypalIPN = new CRM_Core_Payment_PaypalIPN($this->getPaypalRecurSubsequentTransaction());
165 $paypalIPN->main();
166 $renewedMembershipEndDate = $this->membershipRenewalDate($durationUnit, $membershipEndDate);
167 $this->assertEquals($renewedMembershipEndDate, $this->callAPISuccessGetValue('membership', ['return' => 'end_date']));
168 $contribution = $this->callAPISuccess('contribution', 'get', [
169 'contribution_recur_id' => $this->_contributionRecurID,
170 'sequential' => 1,
171 ]);
172 $this->assertEquals(2, $contribution['count']);
173 $this->assertEquals('secondone', $contribution['values'][1]['trxn_id']);
174 $this->callAPISuccessGetCount('line_item', [
175 'entity_id' => $this->ids['membership'],
176 'entity_table' => 'civicrm_membership',
177 ], 2);
178 $this->callAPISuccessGetSingle('line_item', [
179 'contribution_id' => $contribution['values'][1]['id'],
180 'entity_table' => 'civicrm_membership',
181 ]);
182 $this->callAPISuccessGetSingle('membership_payment', ['contribution_id' => $contribution['values'][1]['id']]);
183 }
184
185 /**
186 * Test IPN that we can force membership when the membership payment has been deleted.
187 *
188 * https://lab.civicrm.org/dev/membership/issues/13
189 *
190 * In this scenario the membership payment record was deleted (or not created) for the first contribution but we
191 * 'recover' by using the input membership id.
192 *
193 * @throws \CRM_Core_Exception
194 * @throws \CiviCRM_API3_Exception
195 */
196 public function testIPNPaymentInputMembershipRecurSuccess() {
197 $durationUnit = 'year';
198 $this->setupMembershipRecurringPaymentProcessorTransaction(['duration_unit' => $durationUnit, 'frequency_unit' => $durationUnit]);
199 $membershipPayment = $this->callAPISuccessGetSingle('membership_payment', []);
200 $paypalIPN = new CRM_Core_Payment_PayPalIPN(array_merge($this->getPaypalRecurTransaction(), ['membershipID' => $membershipPayment['membership_id']]));
201 $paypalIPN->main();
202 $membershipEndDate = $this->callAPISuccessGetValue('membership', ['return' => 'end_date']);
203 CRM_Core_DAO::executeQuery('DELETE FROM civicrm_membership_payment WHERE id = ' . $membershipPayment['id']);
204 CRM_Core_DAO::executeQuery("UPDATE civicrm_line_item SET entity_table = 'civicrm_contribution' WHERE entity_table = 'civicrm_membership'");
205
206 $paypalIPN = new CRM_Core_Payment_PaypalIPN(array_merge($this->getPaypalRecurSubsequentTransaction(), ['membershipID' => $membershipPayment['membership_id']]));
207 $paypalIPN->main();
208 $renewedMembershipEndDate = $this->membershipRenewalDate($durationUnit, $membershipEndDate);
209 $this->assertEquals($renewedMembershipEndDate, $this->callAPISuccessGetValue('membership', ['return' => 'end_date']));
210 $contribution = $this->callAPISuccess('contribution', 'get', [
211 'contribution_recur_id' => $this->_contributionRecurID,
212 'sequential' => 1,
213 ]);
214 $this->assertEquals(2, $contribution['count']);
215 $this->assertEquals('secondone', $contribution['values'][1]['trxn_id']);
216 $this->callAPISuccessGetCount('line_item', [
217 'entity_id' => $this->ids['membership'],
218 'entity_table' => 'civicrm_membership',
219 ], 1);
220 $this->callAPISuccessGetSingle('line_item', [
221 'contribution_id' => $contribution['values'][1]['id'],
222 'entity_table' => 'civicrm_membership',
223 ]);
224 $this->callAPISuccessGetSingle('membership_payment', ['contribution_id' => $contribution['values'][1]['id']]);
225 }
226
227 /**
228 * Get IPN style details for an incoming recurring transaction.
229 */
230 public function getPaypalRecurTransaction() {
231 return [
232 'contactID' => $this->_contactID,
233 'contributionID' => $this->_contributionID,
234 'invoice' => $this->_invoiceID,
235 'contributionRecurID' => $this->_contributionRecurID,
236 'mc_gross' => '15.00',
237 'module' => 'contribute',
238 'payer_id' => '4NHUTA7ZUE92C',
239 'payment_status' => 'Completed',
240 'receiver_email' => 'sunil._1183377782_biz_api1.webaccess.co.in',
241 'txn_type' => 'subscr_payment',
242 'last_name' => 'Roberty',
243 'payment_fee' => '0.63',
244 'first_name' => 'Robert',
245 'txn_id' => '8XA571746W2698126',
246 'residence_country' => 'US',
247 ];
248 }
249
250 /**
251 * Get IPN style details for an incoming paypal standard transaction.
252 */
253 public function getPaypalTransaction() {
254 return [
255 'contactID' => $this->_contactID,
256 'contributionID' => $this->_contributionID,
257 'invoice' => $this->_invoiceID,
258 'mc_gross' => '100.00',
259 'mc_fee' => '5.00',
260 'settle_amount' => '95.00',
261 'module' => 'contribute',
262 'payer_id' => 'FV5ZW7TLMQ874',
263 'payment_status' => 'Completed',
264 'receiver_email' => 'sunil._1183377782_biz_api1.webaccess.co.in',
265 'txn_type' => 'web_accept',
266 'last_name' => 'Roberty',
267 'payment_fee' => '0.63',
268 'first_name' => 'Robert',
269 'txn_id' => '8XA571746W2698126',
270 'residence_country' => 'US',
271 'custom' => json_encode(['cgid' => 'test12345']),
272 ];
273 }
274
275 /**
276 * Get IPN-style details for a second incoming transaction.
277 *
278 * @return array
279 */
280 public function getPaypalRecurSubsequentTransaction() {
281 return array_merge($this->getPaypalRecurTransaction(), ['txn_id' => 'secondone']);
282 }
283
284 /**
285 * Test IPN response updates contribution and invoice is attached in mail reciept
286 * Test also AlterIPNData intercepts at the right point and allows for custom processing
287 * The scenario is that a pending contribution exists and the IPN call will update it to completed.
288 * And also if Tax and Invoicing is enabled, this unit test ensure that invoice pdf is attached with email recipet
289 */
290 public function testhookAlterIPNDataOnIPNPaymentSuccess() {
291
292 $pendingStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
293 $completedStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
294 $params = [
295 'payment_processor_id' => $this->_paymentProcessorID,
296 'contact_id' => $this->_contactID,
297 'trxn_id' => NULL,
298 'invoice_id' => $this->_invoiceID,
299 'contribution_status_id' => $pendingStatusID,
300 'is_email_receipt' => TRUE,
301 ];
302 $this->_contributionID = $this->contributionCreate($params);
303 $this->createCustomField();
304 $contribution = $this->callAPISuccess('contribution', 'get', ['id' => $this->_contributionID, 'sequential' => 1]);
305 // assert that contribution created before handling payment via paypal standard has no transaction id set and pending status
306 $this->assertEquals(NULL, $contribution['values'][0]['trxn_id']);
307 $this->assertEquals($pendingStatusID, $contribution['values'][0]['contribution_status_id']);
308 $this->hookClass->setHook('civicrm_postIPNProcess', [$this, 'hookCiviCRMAlterIPNData']);
309 global $_REQUEST;
310 $_REQUEST = ['q' => CRM_Utils_System::url('civicrm/payment/ipn/' . $this->_paymentProcessorID)] + $this->getPaypalTransaction();
311
312 $mut = new CiviMailUtils($this, TRUE);
313 $payment = CRM_Core_Payment::handlePaymentMethod('PaymentNotification', ['processor_id' => $this->_paymentProcessorID]);
314
315 $contribution = $this->callAPISuccess('contribution', 'get', ['id' => $this->_contributionID, 'sequential' => 1]);
316 // assert that contribution is completed after getting response from paypal standard which has transaction id set and completed status
317 $this->assertEquals($_REQUEST['txn_id'], $contribution['values'][0]['trxn_id']);
318 $this->assertEquals($completedStatusID, $contribution['values'][0]['contribution_status_id']);
319 $this->assertEquals('test12345', $contribution['values'][0]['custom_' . $this->_customFieldID]);
320 }
321
322 /**
323 * Allow IPNs to validate when the supplied contact_id has been deleted from the database but there is a valid contact id in the contribution recur object or contribution object
324 */
325 public function testPayPalIPNSuccessDeletedContact() {
326 $contactTobeDeleted = $this->individualCreate();
327 $pendingStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
328 $completedStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
329 $params = [
330 'payment_processor_id' => $this->_paymentProcessorID,
331 'contact_id' => $this->_contactID,
332 'trxn_id' => NULL,
333 'invoice_id' => $this->_invoiceID,
334 'contribution_status_id' => $pendingStatusID,
335 'is_email_receipt' => TRUE,
336 ];
337 $this->_contributionID = $this->contributionCreate($params);
338 $contribution = $this->callAPISuccess('contribution', 'get', ['id' => $this->_contributionID, 'sequential' => 1]);
339 // assert that contribution created before handling payment via paypal standard has no transaction id set and pending status
340 $this->assertEquals(NULL, $contribution['values'][0]['trxn_id']);
341 $this->assertEquals($pendingStatusID, $contribution['values'][0]['contribution_status_id']);
342 $payPalIPNParams = $this->getPaypalTransaction();
343 $payPalIPNParams['contactID'] = $contactTobeDeleted;
344 $this->callAPISuccess('Contact', 'delete', ['id' => $contactTobeDeleted, 'skip_undelete' => 1]);
345 global $_REQUEST;
346 $_REQUEST = ['q' => CRM_Utils_System::url('civicrm/payment/ipn/' . $this->_paymentProcessorID)] + $payPalIPNParams;
347 // Now process the IPN noting that the contact id that was supplied with the IPN has been deleted but there is still a valid one on the contribution id
348 $payment = CRM_Core_Payment::handlePaymentMethod('PaymentNotification', ['processor_id' => $this->_paymentProcessorID]);
349
350 $contribution = $this->callAPISuccess('contribution', 'get', ['id' => $this->_contributionID, 'sequential' => 1]);
351 // assert that contribution is completed after getting response from paypal standard which has transaction id set and completed status
352 $this->assertEquals($_REQUEST['txn_id'], $contribution['values'][0]['trxn_id']);
353 $this->assertEquals($completedStatusID, $contribution['values'][0]['contribution_status_id']);
354 }
355
356 /**
357 * Store Custom data passed in from the PayPalIPN in a custom field
358 */
359 public function hookCiviCRMAlterIPNData($data) {
360 if (!empty($data['custom'])) {
361 $customData = json_decode($data['custom'], TRUE);
362 $customField = $this->callAPISuccess('custom_field', 'get', ['label' => 'TestCustomFieldIPNHook']);
363 $this->callAPISuccess('contribution', 'create', ['id' => $this->_contributionID, 'custom_' . $customField['id'] => $customData['cgid']]);
364 }
365 }
366
367 /**
368 * @return array
369 */
370 protected function createCustomField() {
371 $customGroup = $this->customGroupCreate(['extends' => 'Contribution']);
372 $fields = [
373 'label' => 'TestCustomFieldIPNHook',
374 'data_type' => 'String',
375 'html_type' => 'Text',
376 'custom_group_id' => $customGroup['id'],
377 ];
378 $field = CRM_Core_BAO_CustomField::create($fields);
379 $this->_customFieldID = $field->id;
380 return $customGroup;
381 }
382
383 }