← All changes
|
src/PaymentGateways/Gateways/PayPalStandard/Controllers/PayPalStandardWebhook.php
+199
-0
4.16.3
→
4.17.0
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,9 @@ | ||
| 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. | |
| 33 | 36 | */ |
| 34 | 37 | public function handle() |
| 35 | 38 | { |
| 36 | 39 | $eventData = file_get_contents('php://input'); |
| @@ -55,8 +58,12 @@ | ||
| 55 | 58 | ); |
| 56 | 59 | exit(); |
| 57 | 60 | } |
| 58 | 61 | |
| 62 | + if ( ! $this->verifyEventData($eventData, $donationId, $txnType)) { | |
| 63 | + exit(); | |
| 64 | + } | |
| 65 | + | |
| 59 | 66 | $this->recordIpn($eventData, $donationId); |
| 60 | 67 | $this->recordIpnInDonation($donationId); |
| 61 | 68 | |
| 62 | 69 | /* @var WebhookRegister $webhookRegisterer */ |
| @@ -156,6 +163,198 @@ | ||
| 156 | 163 | * @param int $donationId donation id. |
| 157 | 164 | */ |
| 158 | 165 | do_action('give_paypal_web_accept', $eventData, $donationId); |
| 159 | 166 | } |
| 167 | + } | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * @since 4.16.6.1 | |
| 171 | + */ | |
| 172 | + private function verifyEventData(array $eventData, int $donationId, $txnType): bool | |
| 173 | + { | |
| 174 | + $paymentStatus = strtolower($eventData['payment_status'] ?? ''); | |
| 175 | + | |
| 176 | + if ( ! $this->verifyReceiverEmail($eventData)) { | |
| 177 | + return false; | |
| 178 | + } | |
| 179 | + | |
| 180 | + if (in_array($paymentStatus, ['completed', 'pending'], true)) { | |
| 181 | + if ( ! $this->verifyPaymentAmount($eventData, $donationId)) { | |
| 182 | + return false; | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + if (in_array($paymentStatus, ['refunded', 'reversed'], true)) { | |
| 187 | + if ( ! $this->verifyParentTransactionId($eventData, $donationId)) { | |
| 188 | + return false; | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + return true; | |
| 193 | + } | |
| 194 | + | |
| 195 | + /** | |
| 196 | + * @since 4.16.6.1 | |
| 197 | + */ | |
| 198 | + private function verifyReceiverEmail(array $eventData) | |
| 199 | + { | |
| 200 | + $sitePaypalEmail = trim((string) give_get_option('paypal_email', '')); | |
| 201 | + if ($sitePaypalEmail === '') { | |
| 202 | + return true; | |
| 203 | + } | |
| 204 | + | |
| 205 | + $receiverEmail = strtolower(trim((string) ($eventData['receiver_email'] ?? ''))); | |
| 206 | + $business = strtolower(trim((string) ($eventData['business'] ?? ''))); | |
| 207 | + $siteEmail = strtolower($sitePaypalEmail); | |
| 208 | + | |
| 209 | + if ($receiverEmail === '' && $business === '') { | |
| 210 | + return true; | |
| 211 | + } | |
| 212 | + | |
| 213 | + if ($receiverEmail !== $siteEmail && $business !== $siteEmail) { | |
| 214 | + Log::error( | |
| 215 | + 'PayPal Standard IPN Error', | |
| 216 | + [ | |
| 217 | + 'Message' => sprintf( | |
| 218 | + 'IPN receiver_email (%s) / business (%s) does not match the site PayPal email (%s).', | |
| 219 | + $eventData['receiver_email'] ?? '(not set)', | |
| 220 | + $eventData['business'] ?? '(not set)', | |
| 221 | + $sitePaypalEmail | |
| 222 | + ), | |
| 223 | + 'Event Data' => $eventData, | |
| 224 | + ] | |
| 225 | + ); | |
| 226 | + | |
| 227 | + return false; | |
| 228 | + } | |
| 229 | + | |
| 230 | + return true; | |
| 231 | + } | |
| 232 | + | |
| 233 | + /** | |
| 234 | + * @since 4.16.6.1 | |
| 235 | + */ | |
| 236 | + private function verifyPaymentAmount(array $eventData, $donationId) | |
| 237 | + { | |
| 238 | + try { | |
| 239 | + $donation = Donation::find($donationId); | |
| 240 | + | |
| 241 | + if ( ! $donation) { | |
| 242 | + Log::error( | |
| 243 | + 'PayPal Standard IPN Error', | |
| 244 | + [ | |
| 245 | + 'Message' => sprintf( | |
| 246 | + 'Donation #%d not found.', | |
| 247 | + $donationId | |
| 248 | + ), | |
| 249 | + 'Event Data' => $eventData, | |
| 250 | + ] | |
| 251 | + ); | |
| 252 | + | |
| 253 | + return false; | |
| 254 | + } | |
| 255 | + | |
| 256 | + $currency = strtoupper(trim((string) ($eventData['mc_currency'] ?? ''))); | |
| 257 | + $donationCurrency = strtoupper(trim($donation->amount->getCurrency()->getCode())); | |
| 258 | + | |
| 259 | + if ($currency !== $donationCurrency) { | |
| 260 | + Log::error( | |
| 261 | + 'PayPal Standard IPN Error', | |
| 262 | + [ | |
| 263 | + 'Message' => sprintf( | |
| 264 | + 'IPN currency (%s) does not match donation #%d currency (%s).', | |
| 265 | + $currency, | |
| 266 | + $donationId, | |
| 267 | + $donationCurrency | |
| 268 | + ), | |
| 269 | + 'Event Data' => $eventData, | |
| 270 | + ] | |
| 271 | + ); | |
| 272 | + | |
| 273 | + return false; | |
| 274 | + } | |
| 275 | + | |
| 276 | + $ipnAmount = Money::fromDecimal((float)($eventData['mc_gross'] ?? 0), $currency); | |
| 277 | + | |
| 278 | + if ( ! $ipnAmount->equals($donation->intendedAmount())) { | |
| 279 | + Log::error( | |
| 280 | + 'PayPal Standard IPN Error', | |
| 281 | + [ | |
| 282 | + 'Message' => sprintf( | |
| 283 | + 'IPN amount (%s %s) does not match donation #%d amount (%s %s).', | |
| 284 | + $eventData['mc_gross'] ?? '0', | |
| 285 | + $currency, | |
| 286 | + $donationId, | |
| 287 | + $donation->intendedAmount()->formatToDecimal(), | |
| 288 | + $donationCurrency | |
| 289 | + ), | |
| 290 | + 'Event Data' => $eventData, | |
| 291 | + ] | |
| 292 | + ); | |
| 293 | + | |
| 294 | + return false; | |
| 295 | + } | |
| 296 | + } catch (\Exception $e) { | |
| 297 | + Log::error( | |
| 298 | + 'PayPal Standard IPN Error', | |
| 299 | + [ | |
| 300 | + 'Message' => 'Failed to compare IPN amount to donation amount.', | |
| 301 | + 'Exception' => $e->getMessage(), | |
| 302 | + 'Event Data' => $eventData, | |
| 303 | + ] | |
| 304 | + ); | |
| 305 | + | |
| 306 | + return false; | |
| 307 | + } | |
| 308 | + | |
| 309 | + return true; | |
| 310 | + } | |
| 311 | + | |
| 312 | + /** | |
| 313 | + * @since 4.16.6.1 | |
| 314 | + */ | |
| 315 | + private function verifyParentTransactionId(array $eventData, $donationId) | |
| 316 | + { | |
| 317 | + $parentTxnId = trim((string) ($eventData['parent_txn_id'] ?? '')); | |
| 318 | + if ($parentTxnId === '') { | |
| 319 | + return true; | |
| 320 | + } | |
| 321 | + | |
| 322 | + $donation = Donation::find($donationId); | |
| 323 | + $storedTxnId = $donation ? trim((string) $donation->gatewayTransactionId) : ''; | |
| 324 | + | |
| 325 | + if ($storedTxnId === '') { | |
| 326 | + Log::error( | |
| 327 | + 'PayPal Standard IPN Error', | |
| 328 | + [ | |
| 329 | + 'Message' => sprintf( | |
| 330 | + 'IPN payment_status is %s but donation #%d has no stored transaction ID — cannot process a refund for a donation that was never completed.', | |
| 331 | + strtolower($eventData['payment_status'] ?? ''), | |
| 332 | + $donationId | |
| 333 | + ), | |
| 334 | + 'Event Data' => $eventData, | |
| 335 | + ] | |
| 336 | + ); | |
| 337 | + | |
| 338 | + return false; | |
| 339 | + } | |
| 340 | + | |
| 341 | + if ($parentTxnId !== $storedTxnId) { | |
| 342 | + Log::error( | |
| 343 | + 'PayPal Standard IPN Error', | |
| 344 | + [ | |
| 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 | + ), | |
| 351 | + 'Event Data' => $eventData, | |
| 352 | + ] | |
| 353 | + ); | |
| 354 | + | |
| 355 | + return false; | |
| 356 | + } | |
| 357 | + | |
| 358 | + return true; | |
| 160 | 359 | } |
| 161 | 360 | } |