← 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 | } |