| 1 |
<?php |
| 2 |
|
| 3 |
namespace Give\PaymentGateways\PayPalCommerce; |
| 4 |
|
| 5 |
use Exception; |
| 6 |
use Give\Donations\Models\Donation; |
| 7 |
use Give\Donations\Models\DonationNote; |
| 8 |
use Give\Framework\PaymentGateways\Commands\GatewayCommand; |
| 9 |
use Give\Framework\PaymentGateways\Commands\PaymentComplete; |
| 10 |
use Give\Framework\PaymentGateways\Commands\PaymentRefunded; |
| 11 |
use Give\Framework\PaymentGateways\Contracts\PaymentGatewayRefundable; |
| 12 |
use Give\Framework\PaymentGateways\Exceptions\PaymentGatewayException; |
| 13 |
use Give\Framework\PaymentGateways\PaymentGateway; |
| 14 |
use Give\Framework\Support\ValueObjects\Money; |
| 15 |
use Give\Log\Log; |
| 16 |
use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail; |
| 17 |
use Give\PaymentGateways\PayPalCommerce\PayPalCheckoutSdk\ProcessorResponseError; |
| 18 |
|
| 19 |
/** |
| 20 |
* Class PayPalCommerce |
| 21 |
* |
| 22 |
* Boots the PayPalCommerce gateway and provides its basic registration properties |
| 23 |
* |
| 24 |
* @since 2.9.0 |
| 25 |
*/ |
| 26 |
class PayPalCommerce extends PaymentGateway implements PaymentGatewayRefundable |
| 27 |
{ |
| 28 |
/** |
| 29 |
* @deprecated |
| 30 |
* |
| 31 |
* Use getId or id function to access payment gateway id. |
| 32 |
* |
| 33 |
*/ |
| 34 |
const GATEWAY_ID = 'paypal-commerce'; |
| 35 |
|
| 36 |
/** |
| 37 |
* @since 2.19.0 |
| 38 |
*/ |
| 39 |
public function getLegacyFormFieldMarkup(int $formId, array $args): string |
| 40 |
{ |
| 41 |
return give(AdvancedCardFields::class)->addCreditCardForm($formId); |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* @since 2.19.0 |
| 46 |
* |
| 47 |
* @return string |
| 48 |
*/ |
| 49 |
public static function id(): string |
| 50 |
{ |
| 51 |
return 'paypal-commerce'; |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* @since 2.19.0 |
| 56 |
* |
| 57 |
* @return string |
| 58 |
*/ |
| 59 |
public function getId(): string |
| 60 |
{ |
| 61 |
return self::id(); |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* @since 2.19.0 |
| 66 |
* |
| 67 |
* @return string |
| 68 |
*/ |
| 69 |
public function getName(): string |
| 70 |
{ |
| 71 |
return esc_html__('PayPal Donations', 'give'); |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* @since 2.19.0 |
| 76 |
* |
| 77 |
* @return string |
| 78 |
*/ |
| 79 |
public function getPaymentMethodLabel(): string |
| 80 |
{ |
| 81 |
return esc_html__('Credit Card', 'give'); |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* @since 4.16.9 Capture every order here, and validate the captured amount against the donation. |
| 86 |
* @since 4.16.8.1 Reject a completed order whose amount doesn't match the donation, or whose capture is already recorded against a different donation. |
| 87 |
* @since 4.2.1 updated to use updateOrderFromDonation |
| 88 |
* @since 4.1.0 updated to include 3D Secure validation |
| 89 |
* @since 4.0.0 updated to update and capture payment |
| 90 |
* @since 2.19.0 |
| 91 |
* |
| 92 |
* @param array{payPalOrderId: string|null, payPalAuthorizationId: string|null} $gatewayData |
| 93 |
* @throws \Exception |
| 94 |
*/ |
| 95 |
public function createPayment(Donation $donation, $gatewayData): GatewayCommand |
| 96 |
{ |
| 97 |
$payPalOrderId = $gatewayData['payPalOrderId']; |
| 98 |
|
| 99 |
/** @var Repositories\PayPalOrder $payPalOrderRepository */ |
| 100 |
$payPalOrderRepository = give(Repositories\PayPalOrder::class); |
| 101 |
|
| 102 |
$payPalOrder = $payPalOrderRepository->getApprovedOrder($payPalOrderId); |
| 103 |
|
| 104 |
/* |
| 105 |
* An order is only ever captured here, so one that is already captured belongs to another |
| 106 |
* donation and cannot pay for this one. PayPal captures an order once and answers any |
| 107 |
* further attempt with an error, which is what limits a capture to a single donation. |
| 108 |
*/ |
| 109 |
if ($payPalOrder->status !== 'APPROVED' && $payPalOrder->status !== 'CREATED') { |
| 110 |
throw new PaymentGatewayException(__('PayPal Order is not ready to be captured.', 'give')); |
| 111 |
} |
| 112 |
|
| 113 |
$this->validate3dSecure($payPalOrder); |
| 114 |
|
| 115 |
if ($this->shouldUpdateOrder($donation, $payPalOrder)) { |
| 116 |
$payPalOrderRepository->updateOrderFromDonation($payPalOrderId, $donation); |
| 117 |
} |
| 118 |
|
| 119 |
// ready to capture order, response is the updated PayPal order. |
| 120 |
try { |
| 121 |
$response = $payPalOrderRepository->approveOrder($payPalOrderId); |
| 122 |
} catch (Exception $exception) { |
| 123 |
throw new PaymentGatewayException($this->getCaptureFailureMessage($exception)); |
| 124 |
} |
| 125 |
|
| 126 |
$this->validatePayPalOrder($response); |
| 127 |
$this->validateCapturedAmountMatchesDonation($response, $donation); |
| 128 |
|
| 129 |
$transactionId = $response->purchase_units[0]->payments->captures[0]->id; |
| 130 |
|
| 131 |
give()->payment_meta->update_meta( |
| 132 |
$donation->id, |
| 133 |
'_give_order_id', |
| 134 |
$payPalOrderId |
| 135 |
); |
| 136 |
|
| 137 |
return PaymentComplete::make($transactionId) |
| 138 |
->setPaymentNotes( |
| 139 |
sprintf( |
| 140 |
__('Transaction Successful. PayPal Transaction ID: %1$s PayPal Order ID: %2$s', 'give'), |
| 141 |
$transactionId, |
| 142 |
$payPalOrderId |
| 143 |
) |
| 144 |
); |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* @since 4.5.0 Add Accept Credit Card (Smart Buttons Only) setting. |
| 149 |
* @since 3.0.0 Conditionally add "Transaction Type" setting. |
| 150 |
* @since 2.33.0 Register new payment field type setting. |
| 151 |
* @since 2.27.3 Enable Venmo payment method by default. |
| 152 |
* @since 2.16.2 Add setting "Transaction type". |
| 153 |
*/ |
| 154 |
public function getOptions() |
| 155 |
{ |
| 156 |
/* @var MerchantDetail $merchantDetails */ |
| 157 |
$merchantDetails = give(MerchantDetail::class); |
| 158 |
|
| 159 |
$settings = [ |
| 160 |
[ |
| 161 |
'type' => 'title', |
| 162 |
'id' => 'give_gateway_settings_1', |
| 163 |
'table_html' => false, |
| 164 |
], |
| 165 |
[ |
| 166 |
'id' => 'paypal_commerce_introduction', |
| 167 |
'type' => 'paypal_commerce_introduction', |
| 168 |
], |
| 169 |
[ |
| 170 |
'type' => 'sectionend', |
| 171 |
'id' => 'give_gateway_settings_1', |
| 172 |
'table_html' => false, |
| 173 |
], |
| 174 |
[ |
| 175 |
'type' => 'title', |
| 176 |
'id' => 'give_gateway_settings_2', |
| 177 |
], |
| 178 |
[ |
| 179 |
'name' => esc_html__('Account Country', 'give'), |
| 180 |
'id' => 'paypal_commerce_account_country', |
| 181 |
'type' => 'paypal_commerce_account_country', |
| 182 |
], |
| 183 |
[ |
| 184 |
'name' => esc_html__('Connect With Paypal', 'give'), |
| 185 |
'id' => 'paypal_commerce_account_manger', |
| 186 |
'type' => 'paypal_commerce_account_manger', |
| 187 |
], |
| 188 |
[ |
| 189 |
'name' => esc_html__('Accept Venmo', 'give'), |
| 190 |
'id' => 'paypal_commerce_accept_venmo', |
| 191 |
'type' => 'radio_inline', |
| 192 |
'desc' => esc_html__( |
| 193 |
'Displays a button allowing Donors to pay with Venmo (US-only). Donations still come into your PayPal account and are subject to normal PayPal transaction fees.', |
| 194 |
'give' |
| 195 |
), |
| 196 |
'default' => 'enabled', |
| 197 |
'options' => [ |
| 198 |
'enabled' => esc_html__('Enabled', 'give'), |
| 199 |
'disabled' => esc_html__('Disabled', 'give'), |
| 200 |
], |
| 201 |
], |
| 202 |
[ |
| 203 |
'name' => esc_html__('Payment Field Type', 'give'), |
| 204 |
'desc' => sprintf( |
| 205 |
esc_html__( |
| 206 |
'"Auto" provides the most payment options to the donor as possible, based on your account. "Smart Buttons Only" shows only the payment buttons. There is no "Hosted Only" option at this time due to limitations with PayPal\'s hosted fields. Not sure what this means? %1$sRead here%2$s.', |
| 207 |
'give' |
| 208 |
), |
| 209 |
'<a href="https://docs.givewp.com/paypal-settings" target="_blank">', |
| 210 |
'</a>' |
| 211 |
), |
| 212 |
'id' => 'paypal_payment_field_type', |
| 213 |
'type' => 'radio_inline', |
| 214 |
'options' => [ |
| 215 |
'auto' => esc_html__('Auto', 'give'), |
| 216 |
'smart-buttons' => esc_html__('Smart Buttons Only', 'give'), |
| 217 |
], |
| 218 |
'default' => 'auto', |
| 219 |
], |
| 220 |
[ |
| 221 |
'name' => esc_html__('Accept Credit Card (Smart Buttons Only)', 'give'), |
| 222 |
'id' => 'paypal_commerce_accept_credit_card', |
| 223 |
'type' => 'radio_inline', |
| 224 |
'desc' => esc_html__( |
| 225 |
'Displays a button allowing Donors to pay with Credit Card. This option is only available if "Smart Buttons Only" is selected on Payment Field Type.', |
| 226 |
'give' |
| 227 |
), |
| 228 |
'default' => 'enabled', |
| 229 |
'options' => [ |
| 230 |
'enabled' => esc_html__('Enabled', 'give'), |
| 231 |
'disabled' => esc_html__('Disabled', 'give'), |
| 232 |
], |
| 233 |
], |
| 234 |
[ |
| 235 |
'name' => esc_html__('PayPal Donations Gateway Settings Docs Link', 'give'), |
| 236 |
'id' => 'paypal_commerce_gateway_settings_docs_link', |
| 237 |
'url' => esc_url('http://docs.givewp.com/paypal-donations'), |
| 238 |
'title' => esc_html__('PayPal Donations Gateway Settings', 'give'), |
| 239 |
'type' => 'give_docs_link', |
| 240 |
], |
| 241 |
[ |
| 242 |
'type' => 'sectionend', |
| 243 |
'id' => 'give_gateway_settings_2', |
| 244 |
], |
| 245 |
]; |
| 246 |
|
| 247 |
if (Utils::isDonationTransactionTypeSupported($merchantDetails->accountCountry ?: '')) { |
| 248 |
$settings = give_settings_array_insert( |
| 249 |
$settings, |
| 250 |
'paypal_commerce_accept_venmo', |
| 251 |
[ |
| 252 |
[ |
| 253 |
'name' => esc_html__('Transaction Type', 'give'), |
| 254 |
'desc' => esc_html__( |
| 255 |
'Nonprofits must verify their status to withdraw donations they receive via PayPal. PayPal users that are not verified nonprofits must demonstrate how their donations will be used, once they raise more than $10,000. By default, GiveWP transactions are sent to PayPal as donations. You may change the transaction type using this option if you feel you may not meet PayPal\'s donation requirements.', |
| 256 |
'give' |
| 257 |
), |
| 258 |
'id' => 'paypal_commerce_transaction_type', |
| 259 |
'type' => 'radio_inline', |
| 260 |
'options' => [ |
| 261 |
'donation' => esc_html__('Donation', 'give'), |
| 262 |
'standard' => esc_html__('Standard Transaction', 'give'), |
| 263 |
], |
| 264 |
'default' => 'donation', |
| 265 |
], |
| 266 |
] |
| 267 |
); |
| 268 |
} |
| 269 |
|
| 270 |
if ($merchantDetails->accountIsReady) { |
| 271 |
$settings = give_settings_array_insert( |
| 272 |
$settings, |
| 273 |
'paypal_commerce_gateway_settings_docs_link', |
| 274 |
[ |
| 275 |
[ |
| 276 |
'name' => esc_html__('Collect Billing Details', 'give'), |
| 277 |
'id' => 'paypal_commerce_collect_billing_details', |
| 278 |
'type' => 'radio_inline', |
| 279 |
'desc' => esc_html__( |
| 280 |
'If enabled, required billing address fields are added to PayPal Donations Donation forms. These fields are required to process the transaction when enabled. Billing address details are added to both the donation and donor record in GiveWP.', |
| 281 |
'give' |
| 282 |
), |
| 283 |
'default' => 'disabled', |
| 284 |
'options' => [ |
| 285 |
'enabled' => esc_html__('Enabled', 'give'), |
| 286 |
'disabled' => esc_html__('Disabled', 'give'), |
| 287 |
], |
| 288 |
], |
| 289 |
] |
| 290 |
); |
| 291 |
} |
| 292 |
|
| 293 |
/** |
| 294 |
* filter the settings |
| 295 |
* |
| 296 |
* @since 2.9.6 |
| 297 |
*/ |
| 298 |
return apply_filters('give_get_settings_paypal_commerce', $settings); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* @since 4.16.8.1 Guard against a truncated PayPal response that would otherwise fatal on property access. |
| 303 |
* @since 4.0.0 |
| 304 |
* |
| 305 |
* @throws PaymentGatewayException |
| 306 |
*/ |
| 307 |
private function shouldUpdateOrder(Donation $donation, $payPalOrder): bool |
| 308 |
{ |
| 309 |
$purchaseUnit = $payPalOrder->purchase_units[0] ?? null; |
| 310 |
|
| 311 |
if (! isset($purchaseUnit->amount->value, $purchaseUnit->amount->currency_code)) { |
| 312 |
throw new PaymentGatewayException('PayPal Order does not have an amount.'); |
| 313 |
} |
| 314 |
|
| 315 |
$orderAmount = $purchaseUnit->amount->value; |
| 316 |
$orderCurrency = $purchaseUnit->amount->currency_code; |
| 317 |
$currentOrderAmount = Money::fromDecimal($orderAmount, $orderCurrency); |
| 318 |
|
| 319 |
if (!$currentOrderAmount->equals($donation->amount)) { |
| 320 |
Log::error( |
| 321 |
sprintf( |
| 322 |
'Initial PayPal Order amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s', |
| 323 |
$payPalOrder->id, |
| 324 |
$donation->id |
| 325 |
) |
| 326 |
); |
| 327 |
|
| 328 |
return true; |
| 329 |
} |
| 330 |
|
| 331 |
return false; |
| 332 |
} |
| 333 |
|
| 334 |
/** |
| 335 |
* shouldUpdateOrder() patches the order to the donation amount before the capture, but PayPal |
| 336 |
* is what finally decides how much was taken, so the captured amount is compared to the |
| 337 |
* donation rather than assumed to match. |
| 338 |
* |
| 339 |
* @since 4.16.9 |
| 340 |
* |
| 341 |
* @throws PaymentGatewayException |
| 342 |
*/ |
| 343 |
private function validateCapturedAmountMatchesDonation(object $payPalOrder, Donation $donation): void |
| 344 |
{ |
| 345 |
$capture = $payPalOrder->purchase_units[0]->payments->captures[0] ?? null; |
| 346 |
|
| 347 |
if (! isset($capture->amount->value, $capture->amount->currency_code)) { |
| 348 |
throw new PaymentGatewayException(__('PayPal capture does not have an amount.', 'give')); |
| 349 |
} |
| 350 |
|
| 351 |
$capturedAmount = Money::fromDecimal($capture->amount->value, $capture->amount->currency_code); |
| 352 |
|
| 353 |
if (!$capturedAmount->equals($donation->amount)) { |
| 354 |
Log::error( |
| 355 |
sprintf( |
| 356 |
'Captured PayPal amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s', |
| 357 |
$payPalOrder->id, |
| 358 |
$donation->id |
| 359 |
) |
| 360 |
); |
| 361 |
|
| 362 |
throw new PaymentGatewayException( |
| 363 |
__('Captured PayPal amount does not match donation amount.', 'give') |
| 364 |
); |
| 365 |
} |
| 366 |
} |
| 367 |
|
| 368 |
/** |
| 369 |
* PayPal refuses a capture — most often a declined card — with an HTTP error whose body carries |
| 370 |
* the reason, which the SDK raises as a plain exception. A donor is only shown the message of a |
| 371 |
* PaymentGatewayException, so the reason is read out here rather than left in the log. |
| 372 |
* |
| 373 |
* @since 4.16.9 |
| 374 |
*/ |
| 375 |
private function getCaptureFailureMessage(Exception $exception): string |
| 376 |
{ |
| 377 |
$response = json_decode($exception->getMessage()); |
| 378 |
$issue = $response->details[0]->issue ?? ''; |
| 379 |
$description = $response->details[0]->description ?? ''; |
| 380 |
|
| 381 |
if ($issue === 'INSTRUMENT_DECLINED') { |
| 382 |
return __('The payment method was declined. Please try another card or payment method.', 'give'); |
| 383 |
} |
| 384 |
|
| 385 |
return $description ?: __('PayPal was unable to complete the payment.', 'give'); |
| 386 |
} |
| 387 |
|
| 388 |
/** |
| 389 |
* @since 4.16.9 Read the capture defensively, and report a failed or declined capture's processor response. |
| 390 |
* |
| 391 |
* @throws PaymentGatewayException |
| 392 |
*/ |
| 393 |
private function validatePayPalOrder(object $payPalOrder): void |
| 394 |
{ |
| 395 |
$transaction = $payPalOrder->purchase_units[0]->payments->captures[0] ?? null; |
| 396 |
|
| 397 |
if (! $transaction) { |
| 398 |
throw new PaymentGatewayException(__('PayPal Order does not have a transaction.', 'give')); |
| 399 |
} |
| 400 |
|
| 401 |
/* |
| 402 |
* An invalid CVV or a failed AVS check is reported in the capture's processor response |
| 403 |
* rather than as a PayPal error, so the reason is read from there when there is one. The |
| 404 |
* response is only passed on when PayPal sent an object, since it is typed as one. It is |
| 405 |
* added to the refusal rather than used in its place, because the same code map spells out |
| 406 |
* the checks that passed too — on its own it can read as though nothing went wrong. |
| 407 |
*/ |
| 408 |
if (in_array($transaction->status, ['DECLINED', 'FAILED'], true)) { |
| 409 |
$hasProcessorResponse = isset($transaction->processor_response) |
| 410 |
&& $transaction->processor_response instanceof \stdClass; |
| 411 |
|
| 412 |
$processorError = $hasProcessorResponse |
| 413 |
? ProcessorResponseError::getError($transaction->processor_response) |
| 414 |
: ''; |
| 415 |
|
| 416 |
$message = sprintf( |
| 417 |
__('PayPal Order has been declined. Transaction status:: %s', 'give'), |
| 418 |
$transaction->status |
| 419 |
); |
| 420 |
|
| 421 |
throw new PaymentGatewayException(trim($message . ' ' . $processorError)); |
| 422 |
} |
| 423 |
|
| 424 |
$error = $payPalOrder->details[0]->description ?? ''; |
| 425 |
|
| 426 |
if ($error) { |
| 427 |
throw new PaymentGatewayException( |
| 428 |
sprintf(__('PayPal Order has an error: %s', 'give'), $error) |
| 429 |
); |
| 430 |
} |
| 431 |
|
| 432 |
$this->validate3dSecure($payPalOrder); |
| 433 |
} |
| 434 |
|
| 435 |
/** |
| 436 |
* @since 4.1.0 |
| 437 |
* |
| 438 |
* @throws PaymentGatewayException |
| 439 |
*/ |
| 440 |
private function validate3dSecure(object $payPalOrder): void |
| 441 |
{ |
| 442 |
// Check if the order is not ready for 3D Secure authentication |
| 443 |
if (isset($payPalOrder->payment_source->card->authentication_result->liability_shift) && !in_array($payPalOrder->payment_source->card->authentication_result->liability_shift, ['POSSIBLE', 'YES'])) { |
| 444 |
throw new PaymentGatewayException('Card type and issuing bank are not ready to complete a 3D Secure authentication.'); |
| 445 |
} |
| 446 |
} |
| 447 |
|
| 448 |
/** |
| 449 |
* @since 4.7.0 |
| 450 |
* |
| 451 |
* @throws Exception |
| 452 |
*/ |
| 453 |
public function refundDonation(Donation $donation): PaymentRefunded |
| 454 |
{ |
| 455 |
try { |
| 456 |
$payPalOrderRepository = give(Repositories\PayPalOrder::class); |
| 457 |
$payPalOrderRepository->refundPayment($donation->gatewayTransactionId); |
| 458 |
|
| 459 |
DonationNote::create([ |
| 460 |
'donationId' => $donation->id, |
| 461 |
'content' => sprintf( |
| 462 |
__('Donation refunded in PayPal for transaction ID: %s', 'give'), |
| 463 |
$donation->gatewayTransactionId |
| 464 |
), |
| 465 |
]); |
| 466 |
|
| 467 |
return new PaymentRefunded(); |
| 468 |
} catch (Exception $e) { |
| 469 |
DonationNote::create([ |
| 470 |
'donationId' => $donation->id, |
| 471 |
'content' => sprintf( |
| 472 |
__( |
| 473 |
'Error! Donation %s was NOT refunded. Find more details on the error in the logs at Donations > Tools > Logs. To refund the donation, use the PayPal dashboard tools.', |
| 474 |
'give' |
| 475 |
), |
| 476 |
$donation->id |
| 477 |
), |
| 478 |
]); |
| 479 |
|
| 480 |
throw new PaymentGatewayException(sprintf(__('PayPal API error: %s', 'give'), $e->getMessage())); |
| 481 |
} |
| 482 |
} |
| 483 |
} |
| 484 |
|