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 +161 -19 4.17.0 → 4.18.0.1 View file →
@@ -32,8 +32,9 @@
32 32 *
33 33 * @since 2.19.0
34 34 * @since 2.19.3 Respond with 200 http status to ipn.
35 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.
36 37 */
37 38 public function handle()
38 39 {
39 40 $eventData = file_get_contents('php://input');
@@ -43,9 +44,9 @@
43 44 exit();
44 45 }
45 46
46 47 $donationId = isset($eventData['custom']) ? absint($eventData['custom']) : 0;
47 - $txnType = $eventData['txn_type'];
48 + $txnType = $eventData['txn_type'] ?? '';
48 49
49 50 // ipn verification can be disabled in GiveWP (<=2.15.0).
50 51 // This check will prevent anonymous requests from editing donation, if ipn verification disabled.
51 52 if ( ! $this->verifyDonationId($donationId)) {
@@ -166,8 +167,9 @@
166 167 }
167 168 }
168 169
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.
170 172 * @since 4.16.6.1
171 173 */
172 174 private function verifyEventData(array $eventData, int $donationId, $txnType): bool
173 175 {
@@ -177,15 +179,17 @@
177 179 return false;
178 180 }
179 181
180 182 if (in_array($paymentStatus, ['completed', 'pending'], true)) {
181 - if ( ! $this->verifyPaymentAmount($eventData, $donationId)) {
183 + if ( ! $this->verifyPaymentAmount($eventData, $donationId, $txnType)) {
182 184 return false;
183 185 }
184 186 }
185 187
186 188 if (in_array($paymentStatus, ['refunded', 'reversed'], true)) {
187 - if ( ! $this->verifyParentTransactionId($eventData, $donationId)) {
189 + $refundedDonation = $this->findRefundedDonation($eventData, $donationId);
190 +
191 + if ( ! $refundedDonation || ! $this->verifyRefundAmount($eventData, $refundedDonation)) {
188 192 return false;
189 193 }
190 194 }
191 195
@@ -192,8 +196,9 @@
192 196 return true;
193 197 }
194 198
195 199 /**
200 + * @since 4.18.0.1 Reject the IPN when the site PayPal email or both IPN merchant emails are missing.
196 201 * @since 4.16.6.1
197 202 */
198 203 private function verifyReceiverEmail(array $eventData)
199 204 {
@@ -198,9 +203,17 @@
198 203 private function verifyReceiverEmail(array $eventData)
199 204 {
200 205 $sitePaypalEmail = trim((string) give_get_option('paypal_email', ''));
201 206 if ($sitePaypalEmail === '') {
202 - return true;
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;
203 216 }
204 217
205 218 $receiverEmail = strtolower(trim((string) ($eventData['receiver_email'] ?? '')));
206 219 $business = strtolower(trim((string) ($eventData['business'] ?? '')));
@@ -206,9 +219,17 @@
206 219 $business = strtolower(trim((string) ($eventData['business'] ?? '')));
207 220 $siteEmail = strtolower($sitePaypalEmail);
208 221
209 222 if ($receiverEmail === '' && $business === '') {
210 - return true;
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;
211 232 }
212 233
213 234 if ($receiverEmail !== $siteEmail && $business !== $siteEmail) {
214 235 Log::error(
@@ -230,11 +251,18 @@
230 251 return true;
231 252 }
232 253
233 254 /**
255 + * @since 4.18.0.1 Compare against the gross amount charged by PayPal, which includes recovered fees.
234 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
235 263 */
236 - private function verifyPaymentAmount(array $eventData, $donationId)
264 + private function verifyPaymentAmount(array $eventData, $donationId, $txnType = '')
237 265 {
238 266 try {
239 267 $donation = Donation::find($donationId);
240 268
@@ -273,10 +301,14 @@
273 301 return false;
274 302 }
275 303
276 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 + });
277 309
278 - if ( ! $ipnAmount->equals($donation->intendedAmount())) {
310 + if ( ! $matchingAmounts) {
279 311 Log::error(
280 312 'PayPal Standard IPN Error',
281 313 [
282 314 'Message' => sprintf(
@@ -283,9 +315,11 @@
283 315 'IPN amount (%s %s) does not match donation #%d amount (%s %s).',
284 316 $eventData['mc_gross'] ?? '0',
285 317 $currency,
286 318 $donationId,
287 - $donation->intendedAmount()->formatToDecimal(),
319 + implode(' or ', array_map(static function (Money $chargedAmount) {
320 + return $chargedAmount->formatToDecimal();
321 + }, $chargedAmounts)),
288 322 $donationCurrency
289 323 ),
290 324 'Event Data' => $eventData,
291 325 ]
@@ -309,15 +343,62 @@
309 343 return true;
310 344 }
311 345
312 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.
313 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.
314 383 */
315 - private function verifyParentTransactionId(array $eventData, $donationId)
384 + private function findRefundedDonation(array $eventData, int $donationId): ?Donation
316 385 {
317 386 $parentTxnId = trim((string) ($eventData['parent_txn_id'] ?? ''));
318 387 if ($parentTxnId === '') {
319 - return true;
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;
320 401 }
321 402
322 403 $donation = Donation::find($donationId);
323 404 $storedTxnId = $donation ? trim((string) $donation->gatewayTransactionId) : '';
@@ -334,21 +415,67 @@
334 415 'Event Data' => $eventData,
335 416 ]
336 417 );
337 418
338 - return false;
419 + return null;
339 420 }
340 421
341 - if ($parentTxnId !== $storedTxnId) {
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) {
342 473 Log::error(
343 474 'PayPal Standard IPN Error',
344 475 [
345 - 'Message' => sprintf(
346 - 'IPN parent_txn_id (%s) does not match donation #%d stored transaction ID (%s).',
347 - $parentTxnId,
348 - $donationId,
349 - $storedTxnId
350 - ),
476 + 'Message' => 'Failed to compare IPN refund amount to donation amount.',
477 + 'Exception' => $e->getMessage(),
351 478 'Event Data' => $eventData,
352 479 ]
353 480 );
354 481
@@ -354,7 +481,22 @@
354 481
355 482 return false;
356 483 }
357 484
358 - return true;
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;
359 501 }
360 502 }