PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0.1
GiveWP – Donation Plugin and Fundraising Platform v4.18.0.1
4.18.0.1 4.18.0 4.17.0 4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 All 258 releases
give / src / PaymentGateways / Gateways / PayPalStandard / Controllers / PayPalStandardWebhook.php

PayPalStandardWebhook.php in GiveWP – Donation Plugin and Fundraising Platform 4.18.0.1, at src/PaymentGateways/Gateways/PayPalStandard/Controllers/PayPalStandardWebhook.php

503 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Give\PaymentGateways\Gateways\PayPalStandard\Controllers;
4
5 use Give\Donations\Models\Donation;
6 use Give\Framework\Support\ValueObjects\Money;
7 use Give\Log\Log;
8 use Give\PaymentGateways\Gateways\PayPalStandard\PayPalStandard;
9 use Give\PaymentGateways\Gateways\PayPalStandard\Webhooks\WebhookRegister;
10 use Give\PaymentGateways\Gateways\PayPalStandard\Webhooks\WebhookValidator;
11
12 /**
13 * This class use to handle PayPal ipn.
14 *
15 * @since 2.19.0
16 */
17 class PayPalStandardWebhook
18 {
19
20 /**
21 * @var WebhookValidator
22 */
23 private $webhookValidator;
24
25 public function __construct(WebhookValidator $webhookValidator)
26 {
27 $this->webhookValidator = $webhookValidator;
28 }
29
30 /**
31 * Handle PayPal ipn
32 *
33 * @since 2.19.0
34 * @since 2.19.3 Respond with 200 http status to ipn.
35 * @since 4.16.6.1 Add IPN event-data validation before processing.
36 * @since 4.18.0.1 Default the transaction type when the IPN doesn't include one.
37 */
38 public function handle()
39 {
40 $eventData = file_get_contents('php://input');
41 $eventData = wp_parse_args($eventData);
42
43 if ( ! $this->webhookValidator->verifyEventSignature($eventData)) {
44 exit();
45 }
46
47 $donationId = isset($eventData['custom']) ? absint($eventData['custom']) : 0;
48 $txnType = $eventData['txn_type'] ?? '';
49
50 // ipn verification can be disabled in GiveWP (<=2.15.0).
51 // This check will prevent anonymous requests from editing donation, if ipn verification disabled.
52 if ( ! $this->verifyDonationId($donationId)) {
53 Log::error(
54 'PayPal Standard IPN Error',
55 [
56 'Message' => 'Donation id (from IPN) does not exist.',
57 'Event Data' => $eventData,
58 ]
59 );
60 exit();
61 }
62
63 if ( ! $this->verifyEventData($eventData, $donationId, $txnType)) {
64 exit();
65 }
66
67 $this->recordIpn($eventData, $donationId);
68 $this->recordIpnInDonation($donationId);
69
70 /* @var WebhookRegister $webhookRegisterer */
71 $webhookRegisterer = give(WebhookRegister::class);
72 if ($webhookRegisterer->hasEventRegistered($txnType)) {
73 $webhookRegisterer->getEventHandler($txnType)->processEvent((object)$eventData);
74 }
75
76 $this->supportLegacyActions($txnType, $eventData, $donationId);
77
78 exit;
79 }
80
81 /**
82 * @since 2.19.0
83 *
84 * @param int $donationId
85 *
86 * @param array $eventData
87 */
88 private function recordIpn(array $eventData, $donationId)
89 {
90 update_option(
91 'give_last_paypal_ipn_received',
92 [
93 'auth_status' => 'VERIFIED',
94 'transaction_id' => isset($eventData['txn_id']) ? $eventData['txn_id'] : 'N/A',
95 'payment_id' => $donationId,
96 ],
97 false
98 );
99 }
100
101 /**
102 * @since 2.19.0
103 *
104 * @param int $donationId
105 */
106 private function recordIpnInDonation($donationId)
107 {
108 $currentTimestamp = current_time('timestamp');
109
110 give_insert_payment_note(
111 $donationId,
112 sprintf(
113 __('IPN received on %1$s at %2$s', 'give'),
114 date_i18n('m/d/Y', $currentTimestamp),
115 date_i18n('H:i', $currentTimestamp)
116 )
117 );
118
119 give_update_meta($donationId, 'give_last_paypal_ipn_received', $currentTimestamp);
120 }
121
122 /**
123 * @param $donationId
124 *
125 * @return bool
126 */
127 private function verifyDonationId($donationId)
128 {
129 return $donationId && PayPalStandard::id() === give_get_payment_gateway($donationId);
130 }
131
132 /**
133 * @since 2.19.0
134 *
135 * @param string $txnType
136 * @param array $eventData
137 * @param int $donationId
138 *
139 * @return void
140 */
141 private function supportLegacyActions($txnType, array $eventData, $donationId)
142 {
143 if (has_action('give_paypal_' . $txnType)) {
144 /**
145 * Fires while processing PayPal IPN $txnType.
146 *
147 * Allow PayPal IPN types to be processed separately.
148 *
149 * @since 1.0
150 *
151 * @param array $eventData Encoded data.
152 * @param int $donationId donation id.
153 */
154 do_action("give_paypal_{$txnType}", $eventData, $donationId);
155 } else {
156 /**
157 * Fires while process PayPal IPN.
158 *
159 * Fallback to web accept just in case the txn_type isn't present.
160 *
161 * @since 1.0
162 *
163 * @param array $eventData Encoded data.
164 * @param int $donationId donation id.
165 */
166 do_action('give_paypal_web_accept', $eventData, $donationId);
167 }
168 }
169
170 /**
171 * @since 4.18.0.1 Pass the transaction type to the payment amount check, link refunds to renewals, and verify refund amounts.
172 * @since 4.16.6.1
173 */
174 private function verifyEventData(array $eventData, int $donationId, $txnType): bool
175 {
176 $paymentStatus = strtolower($eventData['payment_status'] ?? '');
177
178 if ( ! $this->verifyReceiverEmail($eventData)) {
179 return false;
180 }
181
182 if (in_array($paymentStatus, ['completed', 'pending'], true)) {
183 if ( ! $this->verifyPaymentAmount($eventData, $donationId, $txnType)) {
184 return false;
185 }
186 }
187
188 if (in_array($paymentStatus, ['refunded', 'reversed'], true)) {
189 $refundedDonation = $this->findRefundedDonation($eventData, $donationId);
190
191 if ( ! $refundedDonation || ! $this->verifyRefundAmount($eventData, $refundedDonation)) {
192 return false;
193 }
194 }
195
196 return true;
197 }
198
199 /**
200 * @since 4.18.0.1 Reject the IPN when the site PayPal email or both IPN merchant emails are missing.
201 * @since 4.16.6.1
202 */
203 private function verifyReceiverEmail(array $eventData)
204 {
205 $sitePaypalEmail = trim((string) give_get_option('paypal_email', ''));
206 if ($sitePaypalEmail === '') {
207 Log::error(
208 'PayPal Standard IPN Error',
209 [
210 'Message' => 'The site PayPal email is not configured, so the IPN merchant cannot be verified.',
211 'Event Data' => $eventData,
212 ]
213 );
214
215 return false;
216 }
217
218 $receiverEmail = strtolower(trim((string) ($eventData['receiver_email'] ?? '')));
219 $business = strtolower(trim((string) ($eventData['business'] ?? '')));
220 $siteEmail = strtolower($sitePaypalEmail);
221
222 if ($receiverEmail === '' && $business === '') {
223 Log::error(
224 'PayPal Standard IPN Error',
225 [
226 'Message' => 'IPN receiver_email and business are both missing, so the IPN merchant cannot be verified.',
227 'Event Data' => $eventData,
228 ]
229 );
230
231 return false;
232 }
233
234 if ($receiverEmail !== $siteEmail && $business !== $siteEmail) {
235 Log::error(
236 'PayPal Standard IPN Error',
237 [
238 'Message' => sprintf(
239 'IPN receiver_email (%s) / business (%s) does not match the site PayPal email (%s).',
240 $eventData['receiver_email'] ?? '(not set)',
241 $eventData['business'] ?? '(not set)',
242 $sitePaypalEmail
243 ),
244 'Event Data' => $eventData,
245 ]
246 );
247
248 return false;
249 }
250
251 return true;
252 }
253
254 /**
255 * @since 4.18.0.1 Compare against the gross amount charged by PayPal, which includes recovered fees.
256 * @since 4.16.6.1
257 *
258 * @param array $eventData PayPal IPN data.
259 * @param int $donationId Donation ID from the IPN "custom" field.
260 * @param string $txnType PayPal IPN transaction type.
261 *
262 * @return bool
263 */
264 private function verifyPaymentAmount(array $eventData, $donationId, $txnType = '')
265 {
266 try {
267 $donation = Donation::find($donationId);
268
269 if ( ! $donation) {
270 Log::error(
271 'PayPal Standard IPN Error',
272 [
273 'Message' => sprintf(
274 'Donation #%d not found.',
275 $donationId
276 ),
277 'Event Data' => $eventData,
278 ]
279 );
280
281 return false;
282 }
283
284 $currency = strtoupper(trim((string) ($eventData['mc_currency'] ?? '')));
285 $donationCurrency = strtoupper(trim($donation->amount->getCurrency()->getCode()));
286
287 if ($currency !== $donationCurrency) {
288 Log::error(
289 'PayPal Standard IPN Error',
290 [
291 'Message' => sprintf(
292 'IPN currency (%s) does not match donation #%d currency (%s).',
293 $currency,
294 $donationId,
295 $donationCurrency
296 ),
297 'Event Data' => $eventData,
298 ]
299 );
300
301 return false;
302 }
303
304 $ipnAmount = Money::fromDecimal((float)($eventData['mc_gross'] ?? 0), $currency);
305 $chargedAmounts = $this->getChargedAmounts($donation, $txnType);
306 $matchingAmounts = array_filter($chargedAmounts, static function (Money $chargedAmount) use ($ipnAmount) {
307 return $ipnAmount->equals($chargedAmount);
308 });
309
310 if ( ! $matchingAmounts) {
311 Log::error(
312 'PayPal Standard IPN Error',
313 [
314 'Message' => sprintf(
315 'IPN amount (%s %s) does not match donation #%d amount (%s %s).',
316 $eventData['mc_gross'] ?? '0',
317 $currency,
318 $donationId,
319 implode(' or ', array_map(static function (Money $chargedAmount) {
320 return $chargedAmount->formatToDecimal();
321 }, $chargedAmounts)),
322 $donationCurrency
323 ),
324 'Event Data' => $eventData,
325 ]
326 );
327
328 return false;
329 }
330 } catch (\Exception $e) {
331 Log::error(
332 'PayPal Standard IPN Error',
333 [
334 'Message' => 'Failed to compare IPN amount to donation amount.',
335 'Exception' => $e->getMessage(),
336 'Event Data' => $eventData,
337 ]
338 );
339
340 return false;
341 }
342
343 return true;
344 }
345
346 /**
347 * Subscription payments reference the initial donation through "custom", but PayPal charges them
348 * the subscription amount, which can differ from the initial donation amount.
349 *
350 * @since 4.18.0.1
351 *
352 * @param Donation $donation Donation referenced by the IPN.
353 * @param string $txnType PayPal IPN transaction type.
354 *
355 * @return Money[] Amounts PayPal was asked to charge for this donation.
356 */
357 private function getChargedAmounts(Donation $donation, $txnType): array
358 {
359 $chargedAmounts = [$donation->amount];
360
361 if ('subscr_payment' === $txnType) {
362 $subscription = $donation->subscription()->get();
363
364 if ($subscription) {
365 $chargedAmounts[] = $subscription->amount;
366 }
367 }
368
369 return $chargedAmounts;
370 }
371
372 /**
373 * Refunds of subscription renewals reference the initial donation through "custom", while
374 * "parent_txn_id" holds the transaction ID of the renewal being refunded.
375 *
376 * @since 4.18.0.1 Renamed from verifyParentTransactionId(). Require parent_txn_id and accept renewals of the same subscription.
377 * @since 4.16.6.1
378 *
379 * @param array $eventData PayPal IPN data.
380 * @param int $donationId Donation ID from the IPN "custom" field.
381 *
382 * @return Donation|null The donation being refunded or reversed, or null when the IPN can't be linked to it.
383 */
384 private function findRefundedDonation(array $eventData, int $donationId): ?Donation
385 {
386 $parentTxnId = trim((string) ($eventData['parent_txn_id'] ?? ''));
387 if ($parentTxnId === '') {
388 Log::error(
389 'PayPal Standard IPN Error',
390 [
391 'Message' => sprintf(
392 'IPN payment_status is %s but parent_txn_id is missing for donation #%d.',
393 strtolower($eventData['payment_status'] ?? ''),
394 $donationId
395 ),
396 'Event Data' => $eventData,
397 ]
398 );
399
400 return null;
401 }
402
403 $donation = Donation::find($donationId);
404 $storedTxnId = $donation ? trim((string) $donation->gatewayTransactionId) : '';
405
406 if ($storedTxnId === '') {
407 Log::error(
408 'PayPal Standard IPN Error',
409 [
410 'Message' => sprintf(
411 'IPN payment_status is %s but donation #%d has no stored transaction ID — cannot process a refund for a donation that was never completed.',
412 strtolower($eventData['payment_status'] ?? ''),
413 $donationId
414 ),
415 'Event Data' => $eventData,
416 ]
417 );
418
419 return null;
420 }
421
422 if ($parentTxnId === $storedTxnId) {
423 return $donation;
424 }
425
426 $renewal = give()->donations->getByGatewayTransactionId($parentTxnId);
427 $subscription = $donation->subscription()->get();
428
429 if ($renewal && $subscription && $renewal->type->isRenewal() && $renewal->subscriptionId === $subscription->id) {
430 return $renewal;
431 }
432
433 Log::error(
434 'PayPal Standard IPN Error',
435 [
436 'Message' => sprintf(
437 'IPN parent_txn_id (%s) does not match donation #%d stored transaction ID (%s) or any of its renewals.',
438 $parentTxnId,
439 $donationId,
440 $storedTxnId
441 ),
442 'Event Data' => $eventData,
443 ]
444 );
445
446 return null;
447 }
448
449 /**
450 * PayPal reports refunds and reversals with a negative mc_gross. Partial refunds are allowed.
451 *
452 * @since 4.18.0.1
453 *
454 * @param array $eventData PayPal IPN data.
455 * @param Donation $refundedDonation Donation being refunded or reversed.
456 *
457 * @return bool
458 */
459 private function verifyRefundAmount(array $eventData, Donation $refundedDonation): bool
460 {
461 $currency = strtoupper(trim((string) ($eventData['mc_currency'] ?? '')));
462 $refundAmount = $eventData['mc_gross'] ?? '';
463
464 try {
465 if (is_numeric($refundAmount) && $currency === $refundedDonation->amount->getCurrency()->getCode()) {
466 $refundAmount = Money::fromDecimal($refundAmount, $currency);
467
468 if ($refundAmount->isNegative() && $refundAmount->absolute()->lessThanOrEqual($refundedDonation->amount)) {
469 return true;
470 }
471 }
472 } catch (\Exception $e) {
473 Log::error(
474 'PayPal Standard IPN Error',
475 [
476 'Message' => 'Failed to compare IPN refund amount to donation amount.',
477 'Exception' => $e->getMessage(),
478 'Event Data' => $eventData,
479 ]
480 );
481
482 return false;
483 }
484
485 Log::error(
486 'PayPal Standard IPN Error',
487 [
488 'Message' => sprintf(
489 'IPN refund amount (%s %s) is not valid for donation #%d (%s %s).',
490 $eventData['mc_gross'] ?? '(not set)',
491 $currency,
492 $refundedDonation->id,
493 $refundedDonation->amount->formatToDecimal(),
494 $refundedDonation->amount->getCurrency()->getCode()
495 ),
496 'Event Data' => $eventData,
497 ]
498 );
499
500 return false;
501 }
502 }
503