PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.16.8.1
GiveWP – Donation Plugin and Fundraising Platform v4.16.8.1
4.18.0 4.17.0 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 All 257 releases
give / src / PaymentGateways / PayPalCommerce / PayPalCommerce.php

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

479 lines 17.5 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\Donations\Repositories\DonationRepository;
9 use Give\Framework\PaymentGateways\Commands\GatewayCommand;
10 use Give\Framework\PaymentGateways\Commands\PaymentComplete;
11 use Give\Framework\PaymentGateways\Commands\PaymentRefunded;
12 use Give\Framework\PaymentGateways\Contracts\PaymentGatewayRefundable;
13 use Give\Framework\PaymentGateways\Exceptions\PaymentGatewayException;
14 use Give\Framework\PaymentGateways\PaymentGateway;
15 use Give\Framework\Support\ValueObjects\Money;
16 use Give\Log\Log;
17 use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail;
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.8.1 Reject a completed order whose amount doesn't match the donation, or whose capture is already recorded against a different donation.
86 * @since 4.2.1 updated to use updateOrderFromDonation
87 * @since 4.1.0 updated to include 3D Secure validation
88 * @since 4.0.0 updated to update and capture payment
89 * @since 2.19.0
90 *
91 * @param array{payPalOrderId: string|null, payPalAuthorizationId: string|null} $gatewayData
92 * @throws \Exception
93 */
94 public function createPayment(Donation $donation, $gatewayData): GatewayCommand
95 {
96 $payPalOrderId = $gatewayData['payPalOrderId'];
97
98 /** @var Repositories\PayPalOrder $payPalOrderRepository */
99 $payPalOrderRepository = give(Repositories\PayPalOrder::class);
100
101 $payPalOrder = $payPalOrderRepository->getApprovedOrder($payPalOrderId);
102
103 if ($payPalOrder->status === 'COMPLETED') {
104 $this->validatePayPalOrder($payPalOrder);
105 $this->validateCompletedOrderAmountMatchesDonation($payPalOrder, $donation);
106
107 $transactionId = $payPalOrder->purchase_units[0]->payments->captures[0]->id;
108
109 $this->validateCaptureNotAlreadyRecorded($transactionId, $donation);
110
111 } elseif ($payPalOrder->status === 'APPROVED' || $payPalOrder->status === 'CREATED') {
112 $this->validate3dSecure($payPalOrder);
113
114 if ($this->shouldUpdateOrder($donation, $payPalOrder)){
115 $payPalOrderRepository->updateOrderFromDonation($payPalOrderId, $donation);
116 }
117
118 // ready to capture order, response is the updated PayPal order.
119 $response = $payPalOrderRepository->approveOrder($payPalOrderId);
120
121 $this->validatePayPalOrder($response);
122
123 $transactionId = $response->purchase_units[0]->payments->captures[0]->id;
124 } else {
125 throw new PaymentGatewayException('PayPal Order status is not found.');
126 }
127
128 give()->payment_meta->update_meta(
129 $donation->id,
130 '_give_order_id',
131 $payPalOrderId
132 );
133
134 return PaymentComplete::make($transactionId)
135 ->setPaymentNotes(
136 sprintf(
137 __('Transaction Successful. PayPal Transaction ID: %1$s PayPal Order ID: %2$s', 'give'),
138 $transactionId,
139 $payPalOrderId
140 )
141 );
142 }
143
144 /**
145 * @since 4.5.0 Add Accept Credit Card (Smart Buttons Only) setting.
146 * @since 3.0.0 Conditionally add "Transaction Type" setting.
147 * @since 2.33.0 Register new payment field type setting.
148 * @since 2.27.3 Enable Venmo payment method by default.
149 * @since 2.16.2 Add setting "Transaction type".
150 */
151 public function getOptions()
152 {
153 /* @var MerchantDetail $merchantDetails */
154 $merchantDetails = give(MerchantDetail::class);
155
156 $settings = [
157 [
158 'type' => 'title',
159 'id' => 'give_gateway_settings_1',
160 'table_html' => false,
161 ],
162 [
163 'id' => 'paypal_commerce_introduction',
164 'type' => 'paypal_commerce_introduction',
165 ],
166 [
167 'type' => 'sectionend',
168 'id' => 'give_gateway_settings_1',
169 'table_html' => false,
170 ],
171 [
172 'type' => 'title',
173 'id' => 'give_gateway_settings_2',
174 ],
175 [
176 'name' => esc_html__('Account Country', 'give'),
177 'id' => 'paypal_commerce_account_country',
178 'type' => 'paypal_commerce_account_country',
179 ],
180 [
181 'name' => esc_html__('Connect With Paypal', 'give'),
182 'id' => 'paypal_commerce_account_manger',
183 'type' => 'paypal_commerce_account_manger',
184 ],
185 [
186 'name' => esc_html__('Accept Venmo', 'give'),
187 'id' => 'paypal_commerce_accept_venmo',
188 'type' => 'radio_inline',
189 'desc' => esc_html__(
190 '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.',
191 'give'
192 ),
193 'default' => 'enabled',
194 'options' => [
195 'enabled' => esc_html__('Enabled', 'give'),
196 'disabled' => esc_html__('Disabled', 'give'),
197 ],
198 ],
199 [
200 'name' => esc_html__('Payment Field Type', 'give'),
201 'desc' => sprintf(
202 esc_html__(
203 '"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.',
204 'give'
205 ),
206 '<a href="https://docs.givewp.com/paypal-settings" target="_blank">',
207 '</a>'
208 ),
209 'id' => 'paypal_payment_field_type',
210 'type' => 'radio_inline',
211 'options' => [
212 'auto' => esc_html__('Auto', 'give'),
213 'smart-buttons' => esc_html__('Smart Buttons Only', 'give'),
214 ],
215 'default' => 'auto',
216 ],
217 [
218 'name' => esc_html__('Accept Credit Card (Smart Buttons Only)', 'give'),
219 'id' => 'paypal_commerce_accept_credit_card',
220 'type' => 'radio_inline',
221 'desc' => esc_html__(
222 '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.',
223 'give'
224 ),
225 'default' => 'enabled',
226 'options' => [
227 'enabled' => esc_html__('Enabled', 'give'),
228 'disabled' => esc_html__('Disabled', 'give'),
229 ],
230 ],
231 [
232 'name' => esc_html__('PayPal Donations Gateway Settings Docs Link', 'give'),
233 'id' => 'paypal_commerce_gateway_settings_docs_link',
234 'url' => esc_url('http://docs.givewp.com/paypal-donations'),
235 'title' => esc_html__('PayPal Donations Gateway Settings', 'give'),
236 'type' => 'give_docs_link',
237 ],
238 [
239 'type' => 'sectionend',
240 'id' => 'give_gateway_settings_2',
241 ],
242 ];
243
244 if (Utils::isDonationTransactionTypeSupported($merchantDetails->accountCountry ?: '')) {
245 $settings = give_settings_array_insert(
246 $settings,
247 'paypal_commerce_accept_venmo',
248 [
249 [
250 'name' => esc_html__('Transaction Type', 'give'),
251 'desc' => esc_html__(
252 '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.',
253 'give'
254 ),
255 'id' => 'paypal_commerce_transaction_type',
256 'type' => 'radio_inline',
257 'options' => [
258 'donation' => esc_html__('Donation', 'give'),
259 'standard' => esc_html__('Standard Transaction', 'give'),
260 ],
261 'default' => 'donation',
262 ],
263 ]
264 );
265 }
266
267 if ($merchantDetails->accountIsReady) {
268 $settings = give_settings_array_insert(
269 $settings,
270 'paypal_commerce_gateway_settings_docs_link',
271 [
272 [
273 'name' => esc_html__('Collect Billing Details', 'give'),
274 'id' => 'paypal_commerce_collect_billing_details',
275 'type' => 'radio_inline',
276 'desc' => esc_html__(
277 '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.',
278 'give'
279 ),
280 'default' => 'disabled',
281 'options' => [
282 'enabled' => esc_html__('Enabled', 'give'),
283 'disabled' => esc_html__('Disabled', 'give'),
284 ],
285 ],
286 ]
287 );
288 }
289
290 /**
291 * filter the settings
292 *
293 * @since 2.9.6
294 */
295 return apply_filters('give_get_settings_paypal_commerce', $settings);
296 }
297
298 /**
299 * @since 4.16.8.1 Guard against a truncated PayPal response that would otherwise fatal on property access.
300 * @since 4.0.0
301 *
302 * @throws PaymentGatewayException
303 */
304 private function shouldUpdateOrder(Donation $donation, $payPalOrder): bool
305 {
306 $purchaseUnit = $payPalOrder->purchase_units[0] ?? null;
307
308 if (! isset($purchaseUnit->amount->value, $purchaseUnit->amount->currency_code)) {
309 throw new PaymentGatewayException('PayPal Order does not have an amount.');
310 }
311
312 $orderAmount = $purchaseUnit->amount->value;
313 $orderCurrency = $purchaseUnit->amount->currency_code;
314 $currentOrderAmount = Money::fromDecimal($orderAmount, $orderCurrency);
315
316 if (!$currentOrderAmount->equals($donation->amount)) {
317 Log::error(
318 sprintf(
319 'Initial PayPal Order amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s',
320 $payPalOrder->id,
321 $donation->id
322 )
323 );
324
325 return true;
326 }
327
328 return false;
329 }
330
331 /**
332 * A completed order's amount cannot be reconciled the way shouldUpdateOrder() does for an
333 * order still pending capture, so a mismatch here is rejected outright.
334 *
335 * @since 4.16.8.1
336 *
337 * @throws PaymentGatewayException
338 */
339 private function validateCompletedOrderAmountMatchesDonation(object $payPalOrder, Donation $donation): void
340 {
341 $purchaseUnit = $payPalOrder->purchase_units[0] ?? null;
342
343 if (! isset($purchaseUnit->amount->value, $purchaseUnit->amount->currency_code)) {
344 throw new PaymentGatewayException('PayPal Order does not have an amount.');
345 }
346
347 $orderAmount = $purchaseUnit->amount->value;
348 $orderCurrency = $purchaseUnit->amount->currency_code;
349 $completedOrderAmount = Money::fromDecimal($orderAmount, $orderCurrency);
350
351 if (!$completedOrderAmount->equals($donation->amount)) {
352 Log::error(
353 sprintf(
354 'Completed PayPal Order amount does not match donation amount. PayPal Order ID: %s, Donation ID: %s',
355 $payPalOrder->id,
356 $donation->id
357 )
358 );
359
360 throw new PaymentGatewayException('PayPal Order amount does not match donation amount.');
361 }
362 }
363
364 /**
365 * @since 4.16.8.1
366 *
367 * @throws PaymentGatewayException
368 */
369 private function validateCaptureNotAlreadyRecorded(string $transactionId, Donation $donation): void
370 {
371 /**
372 * Guard against replaying the same capture for another donation; allow
373 * retry of the same donation. Note: not atomic with PaymentComplete save
374 * — concurrent replays could both pass; a UNIQUE DB constraint is the
375 * future hardening for that race.
376 */
377 $existingDonation = give(DonationRepository::class)
378 ->queryByGatewayTransactionId($transactionId)
379 ->where('ID', $donation->id, '!=')
380 ->get();
381
382 if ($existingDonation) {
383 Log::error(
384 sprintf(
385 'PayPal capture is already recorded against a different donation. Capture ID: %s, Donation ID: %s, Existing Donation ID: %s',
386 $transactionId,
387 $donation->id,
388 $existingDonation->id
389 )
390 );
391
392 throw new PaymentGatewayException('This PayPal transaction has already been recorded for another donation.');
393 }
394 }
395
396 /**
397 * @throws PaymentGatewayException
398 */
399 private function validatePayPalOrder(object $payPalOrder): void
400 {
401 $transaction = $payPalOrder->purchase_units[0]->payments->captures[0];
402
403 $errors = property_exists($payPalOrder, 'details') ? $payPalOrder->details[0] : [];
404
405 if (!$transaction) {
406 throw new PaymentGatewayException('PayPal Order does not have a transaction.');
407 }
408
409 if ($transaction->status === "DECLINED") {
410 $errorMessage = sprintf(
411 __('PayPal Order has been declined. Transaction status:: %s', 'give'),
412 $transaction->status
413 );
414
415 throw new PaymentGatewayException($errorMessage);
416 }
417
418 if (!empty($errors)) {
419 $errorMessage = sprintf(
420 __('PayPal Order has an error: %s', 'give'),
421 $errors->issue[0]->description
422 );
423
424 throw new PaymentGatewayException($errorMessage);
425 }
426
427 $this->validate3dSecure($payPalOrder);
428 }
429
430 /**
431 * @since 4.1.0
432 *
433 * @throws PaymentGatewayException
434 */
435 private function validate3dSecure(object $payPalOrder): void
436 {
437 // Check if the order is not ready for 3D Secure authentication
438 if (isset($payPalOrder->payment_source->card->authentication_result->liability_shift) && !in_array($payPalOrder->payment_source->card->authentication_result->liability_shift, ['POSSIBLE', 'YES'])) {
439 throw new PaymentGatewayException('Card type and issuing bank are not ready to complete a 3D Secure authentication.');
440 }
441 }
442
443 /**
444 * @since 4.7.0
445 *
446 * @throws Exception
447 */
448 public function refundDonation(Donation $donation): PaymentRefunded
449 {
450 try {
451 $payPalOrderRepository = give(Repositories\PayPalOrder::class);
452 $payPalOrderRepository->refundPayment($donation->gatewayTransactionId);
453
454 DonationNote::create([
455 'donationId' => $donation->id,
456 'content' => sprintf(
457 __('Donation refunded in PayPal for transaction ID: %s', 'give'),
458 $donation->gatewayTransactionId
459 ),
460 ]);
461
462 return new PaymentRefunded();
463 } catch (Exception $e) {
464 DonationNote::create([
465 'donationId' => $donation->id,
466 'content' => sprintf(
467 __(
468 '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.',
469 'give'
470 ),
471 $donation->id
472 ),
473 ]);
474
475 throw new PaymentGatewayException(sprintf(__('PayPal API error: %s', 'give'), $e->getMessage()));
476 }
477 }
478 }
479