← All changes
|
src/PaymentGateways/Gateways/PayPalStandard/Controllers/PayPalStandardWebhook.php
+342
-1
4.15.2
→
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 | } |