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
← All changes | src/PaymentGateways/Gateways/PayPalStandard/Controllers/PayPalStandardWebhook.php +342 -1 4.15.4 → 4.18.0.1 View file →
@@ -1,8 +1,10 @@
1 1 <?php
2 2
3 3 namespace Give\PaymentGateways\Gateways\PayPalStandard\Controllers;
4 4
5 +use Give\Donations\Models\Donation;
6 +use Give\Framework\Support\ValueObjects\Money;
5 7 use Give\Log\Log;
6 8 use Give\PaymentGateways\Gateways\PayPalStandard\PayPalStandard;
7 9 use Give\PaymentGateways\Gateways\PayPalStandard\Webhooks\WebhookRegister;
8 10 use Give\PaymentGateways\Gateways\PayPalStandard\Webhooks\WebhookValidator;
@@ -29,8 +31,10 @@
29 31 * Handle PayPal ipn
30 32 *
31 33 * @since 2.19.0
32 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.
33 37 */
34 38 public function handle()
35 39 {
36 40 $eventData = file_get_contents('php://input');
@@ -40,9 +44,9 @@
40 44 exit();
41 45 }
42 46
43 47 $donationId = isset($eventData['custom']) ? absint($eventData['custom']) : 0;
44 - $txnType = $eventData['txn_type'];
48 + $txnType = $eventData['txn_type'] ?? '';
45 49
46 50 // ipn verification can be disabled in GiveWP (<=2.15.0).
47 51 // This check will prevent anonymous requests from editing donation, if ipn verification disabled.
48 52 if ( ! $this->verifyDonationId($donationId)) {
@@ -55,8 +59,12 @@
55 59 );
56 60 exit();
57 61 }
58 62
63 + if ( ! $this->verifyEventData($eventData, $donationId, $txnType)) {
64 + exit();
65 + }
66 +
59 67 $this->recordIpn($eventData, $donationId);
60 68 $this->recordIpnInDonation($donationId);
61 69
62 70 /* @var WebhookRegister $webhookRegisterer */
@@ -156,6 +164,339 @@
156 164 * @param int $donationId donation id.
157 165 */
158 166 do_action('give_paypal_web_accept', $eventData, $donationId);
159 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;
160 501 }
161 502 }