PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.16.9
GiveWP – Donation Plugin and Fundraising Platform v4.16.9
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 2.3.1 2.3.2 2.30.0 All 255 releases
give / src / PaymentGateways / PayPalCommerce / PayPalCommerce.php

PayPalCommerce.php in GiveWP – Donation Plugin and Fundraising Platform 4.16.9, at src/PaymentGateways/PayPalCommerce/PayPalCommerce.php

484 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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