PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / Processor.php

Processor.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/PaymentMethods/PayPalGateway/Processor.php

1,172 lines 52.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4
5 use FluentCart\App\Events\Subscription\SubscriptionActivated;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
9 use FluentCart\App\Helpers\StatusHelper;
10 use FluentCart\App\Models\Order;
11 use FluentCart\App\Models\OrderTransaction;
12 use FluentCart\App\Models\Subscription;
13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
15 use FluentCart\App\Services\DateTime\DateTime;
16 use FluentCart\App\Services\Payments\PaymentHelper;
17 use FluentCart\App\Services\Payments\PaymentInstance;
18 use FluentCart\Framework\Support\Arr;
19
20 class Processor
21 {
22 /**
23 * Does this order-create error actually implicate the vault attributes?
24 *
25 * PayPal reports the offending field path in details[].field, which is the
26 * structural signal — it points straight at attributes/vault when the vault
27 * block is the problem, and elsewhere when it is not. details[].issue is
28 * matched too, against a deliberately small list: guessing broadly here would
29 * recreate the bug this method exists to prevent, so anything unrecognised is
30 * treated as unrelated and the error is returned untouched.
31 *
32 * The issue list is filterable because PayPal can introduce codes faster than
33 * a core release can follow, and a missing code should be correctable without
34 * one.
35 *
36 * @param mixed $error
37 * @return bool
38 */
39 public static function isVaultRejection($error): bool
40 {
41 if (!is_wp_error($error)) {
42 return false;
43 }
44
45 $body = $error->get_error_data();
46 if (!is_array($body)) {
47 return false;
48 }
49
50 $vaultIssues = apply_filters('fluent_cart/payments/paypal_vault_rejection_issues', [
51 'PAYMENT_SOURCE_CANNOT_BE_USED',
52 'PAYMENT_SOURCE_NOT_VAULTABLE',
53 'VAULTING_NOT_ENABLED',
54 'MERCHANT_NOT_ENABLED_FOR_VAULTING',
55 'VAULT_ID_NOT_SUPPORTED',
56 ]);
57
58 foreach ((array) Arr::get($body, 'details', []) as $detail) {
59 if (!is_array($detail)) {
60 continue;
61 }
62
63 // Structural: PayPal names the field it rejected.
64 $field = strtolower((string) Arr::get($detail, 'field', ''));
65 if ($field !== '' && strpos($field, 'vault') !== false) {
66 return true;
67 }
68
69 $issue = strtoupper((string) Arr::get($detail, 'issue', ''));
70 if ($issue !== '' && in_array($issue, $vaultIssues, true)) {
71 return true;
72 }
73 }
74
75 return false;
76 }
77
78 public function handleSinglePayment(PaymentInstance $paymentInstance, $args = [])
79 {
80 $transaction = $paymentInstance->transaction;
81 $order = $paymentInstance->order;
82
83 $currency = $transaction->currency;
84 $itemsSubTotal = 0;
85 $formattedItems = [];
86
87 foreach ($order->order_items as $item) {
88 $quantity = $item->quantity ?? 1;
89 $perQuantity = $this->toDecimal($item->line_total / $quantity, $currency);
90 $title = $item->post_title . ' ' . $item->title;
91
92 $formattedItems[] = [
93 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
94 'description' => strlen($title) > 4000 ? substr($title, 0, 3997) . '...' : $title,
95 'unit_amount' => [
96 'currency_code' => $currency,
97 'value' => PayPalHelper::formatDecimalAmount($perQuantity, $currency),
98 ],
99 'quantity' => $quantity,
100 ];
101
102 $itemsSubTotal += $perQuantity * $quantity;
103 }
104
105 $chargingAmount = $this->toDecimal($transaction->total, $currency);
106 $pushedTotal = $itemsSubTotal;
107
108
109 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
110 $purchaseUnits = [
111 'reference_id' => $transaction->uuid, // This is the order UUID
112 'amount' => [ // https://developer.paypal.com/docs/api/orders/v2/#definition-amount_breakdown
113 'currency_code' => $currency,
114 'value' => PayPalHelper::formatDecimalAmount($chargingAmount, $currency),
115 'breakdown' => [
116 'item_total' => [
117 'currency_code' => $currency,
118 'value' => PayPalHelper::formatDecimalAmount($itemsSubTotal, $currency),
119 ]
120 ]
121 ],
122 'items' => $formattedItems
123 ];
124
125 // if there is no defined credential for specific mode,
126 // then add merchantId as it's a partner app connection
127 $payPalSettings = new PayPalSettingsBase();
128 if ($merchantId = $payPalSettings->getMerchantId()) {
129 if ($payPalSettings->getProviderType() === 'api_keys') {
130 $purchaseUnits['payee'] = [
131 "merchant_id" => $merchantId
132 ];
133 }
134 }
135
136 if ($order->shipping_total > 0) {
137 $shippingAmount = $this->toDecimal($order->shipping_total, $currency);
138 $purchaseUnits['amount']['breakdown']['shipping'] = [
139 'currency_code' => $currency,
140 'value' => PayPalHelper::formatDecimalAmount($shippingAmount, $currency),
141 ];
142 $pushedTotal += $shippingAmount;
143 }
144
145
146
147 $taxBehavior = (int) $order->tax_behavior;
148 $exclusiveTaxTotal = (int) $order->getMeta('exclusive_tax_total');
149 $storeTaxBehavior = (int) $order->getMeta('store_tax_behavior');
150 $feeTax = (int) $order->getMeta('fee_tax');
151
152 // Fallback: if meta missing (old order), use tax_behavior as store_tax_behavior
153 if (empty($storeTaxBehavior) && $taxBehavior > 0) {
154 $storeTaxBehavior = $taxBehavior;
155 }
156
157 if ($taxBehavior === 1) {
158 // Pure exclusive: all tax is additive on top of item prices.
159 // tax_total includes product + fee tax (both exclusive).
160 $taxTotal = $this->toDecimal($order->tax_total, $currency) + $this->toDecimal($order->shipping_tax, $currency);
161 } elseif ($taxBehavior === 3) {
162 // Mixed: only exclusive product + fee tax is additive; shipping conditional.
163 $taxTotal = $this->toDecimal($exclusiveTaxTotal, $currency);
164 if ($storeTaxBehavior === 1) {
165 // Store is exclusive: fees and shipping are also exclusive.
166 $taxTotal += $this->toDecimal($order->shipping_tax, $currency);
167 $taxTotal += $this->toDecimal($feeTax, $currency);
168 }
169 } else {
170 $taxTotal = 0;
171 }
172
173 if ($taxTotal > 0) {
174 $purchaseUnits['amount']['breakdown']['tax_total'] = [
175 'currency_code' => $currency,
176 'value' => PayPalHelper::formatDecimalAmount($taxTotal, $currency),
177 ];
178 $pushedTotal += $taxTotal;
179 }
180
181 if ($chargingAmount < $pushedTotal) {
182 $discount = $pushedTotal - $chargingAmount;
183 $purchaseUnits['amount']['breakdown']['discount'] = [
184 'currency_code' => $currency,
185 'value' => PayPalHelper::formatDecimalAmount($discount, $currency),
186 ];
187 } else if ($chargingAmount > $pushedTotal) {
188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
189 $formattedItems[] = [
190 'name' => __('Adjustment Amount', 'fluent-cart'),
191 'unit_amount' => [
192 'currency_code' => $currency,
193 'value' => PayPalHelper::formatDecimalAmount($extraChargeNeedToBeAdded, $currency),
194 ],
195 'quantity' => 1,
196 ];
197
198 $purchaseUnits['items'] = $formattedItems;
199
200 //now the total amount need to be adjusted with item total value
201 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
202 $purchaseUnits['amount']['breakdown']['item_total']['value'] = PayPalHelper::formatDecimalAmount($adjustedItemTotal, $currency);
203 }
204
205 // System (auto-charged, store-billed) subscription checkout: vault the
206 // buyer's PayPal account during this purchase (Vault v3 save-on-success)
207 // so future renewal invoices can be charged merchant-initiated. The buyer
208 // sees and approves the save agreement inside PayPal's own approval UI.
209 // Vaulting on a plain one-time order cannot be requested from outside core:
210 // the vault_attributes filter below fires only once this branch is already
211 // taken, so it can shape a vault but never ask for one. This filter is the
212 // PayPal counterpart of fluent_cart/payments/stripe_onetime_intent_args, and
213 // it is what lets the saved-payment-methods module vault on buyer consent.
214 // Defaults to the existing value, so with no listener behaviour is unchanged.
215 $vaultOnSuccess = apply_filters(
216 'fluent_cart/payments/paypal_vault_one_time',
217 !empty($args['vault_on_success']),
218 [
219 'order' => $order,
220 'transaction' => $transaction,
221 'subscription' => $paymentInstance->subscription,
222 ]
223 );
224
225 $extraBody = [];
226 if ($vaultOnSuccess) {
227 $vaultAttributes = apply_filters('fluent_cart/paypal/vault_attributes', [
228 'store_in_vault' => 'ON_SUCCESS',
229 'usage_type' => 'MERCHANT',
230 'customer_type' => 'CONSUMER',
231 ], [
232 'order' => $order,
233 'subscription' => $paymentInstance->subscription,
234 ]);
235
236 $extraBody['payment_source'] = [
237 'paypal' => [
238 'attributes' => ['vault' => $vaultAttributes],
239 'experience_context' => [
240 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
241 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
242 'shipping_preference' => 'NO_SHIPPING',
243 ],
244 ],
245 ];
246 }
247
248 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
249
250 // Vaulting is a convenience; the purchase is the point. A merchant account
251 // not approved for vaulting can reject the order outright because of the
252 // vault attributes, and failing the sale over a save the buyer merely
253 // opted into would be the wrong trade. Retry once without them and let
254 // listeners record that this account cannot vault, so the saving UI can
255 // stop being offered instead of failing silently on every order.
256 //
257 // ONLY for an error that actually implicates the vault attributes. An auth
258 // failure, rate limit, malformed amount or transport error is not evidence
259 // that this account cannot vault: retrying would not fix it, and telling a
260 // listener otherwise would switch saving off for a perfectly capable
261 // account on the strength of an unrelated outage.
262 if (is_wp_error($paypalOrder) && $vaultOnSuccess && self::isVaultRejection($paypalOrder)) {
263 do_action('fluent_cart/payments/paypal_vault_rejected', [
264 'order' => $order,
265 'transaction' => $transaction,
266 'error' => $paypalOrder,
267 ]);
268
269 unset($extraBody['payment_source']['paypal']['attributes']);
270
271 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
272 }
273
274 if (is_wp_error($paypalOrder)) {
275 return $paypalOrder;
276 }
277
278 $paypalOrderId = Arr::get($paypalOrder, 'id');
279
280 $transaction->update([
281 'meta' => array_merge($transaction->meta ?? [], ['paypal_order_id' => $paypalOrderId])
282 ]);
283
284 return [
285 'nextAction' => 'paypal',
286 'actionName' => 'custom',
287 'status' => 'success',
288 'data' => [
289 'order' => [
290 'uuid' => $order->uuid,
291 ],
292 'transaction' => [
293 'uuid' => $transaction->uuid,
294 ]
295 ],
296 'message' => __('Order has been placed successfully', 'fluent-cart'),
297 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
298 'response' => [
299 'paypalOrderId' => $paypalOrderId,
300 ]
301 ];
302 }
303
304 /**
305 * Zero-payable system subscription checkout (free trial): a $0 PayPal order
306 * is invalid, so the buyer's PayPal account is vaulted via a Vault v3 setup
307 * token; confirmVaultSetup() exchanges it, completes the $0 order, and the
308 * trial-end invoice is charged off-session like any other system renewal.
309 * The save agreement is carried by PayPal's own approval popup; the checkout
310 * page shows the informational disclosure next to the buttons.
311 */
312 public function handleSetupOnlyPayment(PaymentInstance $paymentInstance)
313 {
314 $order = $paymentInstance->order;
315 $transaction = $paymentInstance->transaction;
316
317 $setupToken = API::makeRequest('vault/setup-tokens', 'v3', 'POST', [
318 'payment_source' => [
319 'paypal' => [
320 'usage_type' => 'MERCHANT',
321 'customer_type' => 'CONSUMER',
322 'experience_context' => [
323 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
324 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
325 'shipping_preference' => 'NO_SHIPPING',
326 ],
327 ],
328 ],
329 ]);
330
331 if (is_wp_error($setupToken)) {
332 return $setupToken;
333 }
334
335 $setupTokenId = Arr::get($setupToken, 'id');
336
337 if (!$setupTokenId) {
338 return new \WP_Error('setup_token_failed', __('PayPal did not return a setup token.', 'fluent-cart'));
339 }
340
341 // confirmVaultSetup() binds the buyer's approval to this transaction by
342 // this id; the write takes the same lock as confirmation so a
343 // replacement can never interleave with an in-flight confirm.
344 if (!self::acquireVaultTransactionLock($transaction->uuid)) {
345 return new \WP_Error('setup_in_progress', __('Another payment confirmation is in progress. Please try again.', 'fluent-cart'));
346 }
347
348 try {
349 $transaction->update([
350 'meta' => array_merge($transaction->meta ?? [], ['paypal_setup_token_id' => $setupTokenId])
351 ]);
352 } finally {
353 self::releaseVaultTransactionLock($transaction->uuid);
354 }
355
356 return [
357 'nextAction' => 'paypal',
358 'actionName' => 'custom',
359 'status' => 'success',
360 'data' => [
361 'order' => [
362 'uuid' => $order->uuid,
363 ],
364 'transaction' => [
365 'uuid' => $transaction->uuid,
366 ]
367 ],
368 'message' => __('Order has been placed successfully', 'fluent-cart'),
369 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
370 'response' => [
371 'setupTokenId' => $setupTokenId,
372 ]
373 ];
374 }
375
376 /**
377 * Vault-flow lock, keyed on the transaction uuid — shared by the setup-token
378 * binding write and the confirmation endpoint so token replacement and
379 * confirmation of one transaction always serialize.
380 */
381 public static function acquireVaultTransactionLock($transactionUuid)
382 {
383 global $wpdb;
384
385 $result = $wpdb->get_var($wpdb->prepare(
386 'SELECT GET_LOCK(%s, %d)',
387 'fluent_cart_paypal_vault_' . md5($transactionUuid),
388 10
389 ));
390
391 return (string) $result === '1';
392 }
393
394 public static function releaseVaultTransactionLock($transactionUuid)
395 {
396 global $wpdb;
397
398 $wpdb->get_var($wpdb->prepare(
399 'SELECT RELEASE_LOCK(%s)',
400 'fluent_cart_paypal_vault_' . md5($transactionUuid)
401 ));
402 }
403
404 /**
405 * Exchange an approved setup token for a durable payment token, persist it
406 * on the system subscription, and complete the $0 order — the trial then
407 * activates through the normal status-sync path.
408 *
409 * @param OrderTransaction $transaction
410 * @param string $setupTokenId
411 * @return true|\WP_Error
412 */
413 public function confirmVaultSetup(OrderTransaction $transaction, $setupTokenId)
414 {
415 // A prior confirmation may have died between marking the transaction
416 // succeeded and syncing the order — always re-run the idempotent sync.
417 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
418 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
419 return true;
420 }
421
422 /** @var Subscription|null $subscription */
423 $subscription = Subscription::query()->find($transaction->subscription_id);
424
425 if (!$subscription || !$subscription->isSystem()) {
426 return new \WP_Error('invalid_subscription', __('No auto-charged subscription is attached to this transaction.', 'fluent-cart'));
427 }
428
429 // Keyed on the setup token: a double-fired confirmation replays the
430 // original payment token instead of vaulting twice.
431 $paymentToken = API::makeRequest('vault/payment-tokens', 'v3', 'POST', [
432 'payment_source' => [
433 'token' => [
434 'id' => $setupTokenId,
435 'type' => 'SETUP_TOKEN',
436 ],
437 ],
438 ], '', [
439 'PayPal-Request-Id' => 'fct_paypal_pt_' . md5($setupTokenId),
440 ]);
441
442 if (is_wp_error($paymentToken)) {
443 return $paymentToken;
444 }
445
446 $tokenId = Arr::get($paymentToken, 'id');
447
448 if (!$tokenId) {
449 return new \WP_Error('vault_failed', __('PayPal did not return a saved payment method.', 'fluent-cart'));
450 }
451
452 $vaultCustomerId = Arr::get($paymentToken, 'customer.id', '');
453 if ($vaultCustomerId && !$subscription->vendor_customer_id) {
454 $subscription->vendor_customer_id = $vaultCustomerId;
455 $subscription->save();
456 }
457
458 $paypalSource = Arr::get($paymentToken, 'payment_source.paypal', []);
459 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
460 'email' => Arr::get($paypalSource, 'email_address', ''),
461 'payer_id' => Arr::get($paypalSource, 'account_id', ''),
462 'name' => trim(Arr::get($paypalSource, 'name.given_name', '') . ' ' . Arr::get($paypalSource, 'name.surname', '')),
463 ]);
464 $billingInfo['vendor_method_id'] = $tokenId;
465
466 $subscription->updateMeta('active_payment_method', $billingInfo);
467
468 $subscription->addLog(
469 'PayPal account saved',
470 __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
471 'info'
472 );
473
474 $transaction->fill([
475 'status' => Status::TRANSACTION_SUCCEEDED,
476 'payment_method' => 'paypal',
477 ]);
478 $transaction->save();
479
480 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
481
482 return true;
483 }
484
485 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
486 {
487 $orderType = $paymentInstance->order->type;
488 $subscription = $paymentInstance->subscription;
489 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
490 $initialAmount = (int)$subscription->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
491 $status = Status::SUBSCRIPTION_INTENDED;
492
493 if ($orderType == 'renewal') {
494 $requiredBillTimes = $subscription->getRequiredBillTimes();
495
496 if ($requiredBillTimes === -1) {
497 return new \WP_Error('already_completed', __('Invalid bill times for the subscription.', 'fluent-cart'));
498 }
499
500 $data = [
501 'order_id' => $subscription->parent_order_id,
502 'product_id' => $subscription->product_id,
503 'variation_id' => $subscription->variation_id,
504 'trial_days' => $subscription->getReactivationTrialDays(), // trial days for reactivation
505 'billing_interval' => $subscription->billing_interval,
506 'currency' => $paymentInstance->order->currency,
507 'interval_count' => 1, // 1
508 'recurring_amount' => $subscription->getCurrentRenewalAmount(), // default recurring total in cents
509 'signup_fee' => 0, // default setup fee in cents ($0.00)
510 'bill_times' => $requiredBillTimes, // 0 for unlimited
511 ];
512 $status = $subscription->status;
513 } else {
514 $data = [
515 'order_id' => $subscription->parent_order_id,
516 'product_id' => $subscription->product_id,
517 'variation_id' => $subscription->variation_id,
518 'trial_days' => $subscription->trial_days,
519 'billing_interval' => $subscription->billing_interval,
520 'currency' => $paymentInstance->order->currency,
521 'interval_count' => 1, // 1
522 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
523 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
524 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
525 ];
526
527 }
528
529 $paypalPlan = PayPalHelper::getPayPalPlan($data);
530
531 if (is_wp_error($paypalPlan)) {
532 return $paypalPlan;
533 }
534
535 $subscriptionUpdateFields = [
536 'status' => $status,
537 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
538 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
539 ];
540
541 $subscription->update($subscriptionUpdateFields);
542
543 if ($orderType == 'renewal' && !empty($data['trial_days'])) {
544 $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
545 }
546
547 return [
548 'status' => 'success',
549 'nextAction' => 'paypal',
550 'actionName' => 'custom',
551 'message' => __('Order has been placed successfully', 'fluent-cart'),
552 'data' => [
553 'order' => [
554 'uuid' => $paymentInstance->order->uuid,
555 ],
556 'transaction' => [
557 'uuid' => $paymentInstance->transaction->uuid,
558 ],
559 'subscription' => [
560 'uuid' => $subscription->uuid,
561 ]
562 ],
563 'response' => [
564 'planId' => Arr::get($paypalPlan, 'id')
565 ]
566 ];
567 }
568
569 /**
570 * Confirm payment success
571 * Currently used by:
572 * @param OrderTransaction $transaction
573 * @param array $args
574 * @param array $transactionArgs
575 * string vendor_charge_id - The intent_id from paypal
576 * string total - The amount charged in cents
577 * string status - The status of the transaction ('succeeded', 'pending', etc.))
578 * array payer - The payer information from PayPal.
579 * array payment_source - The payment source information from PayPal.
580 *
581 * @param string $args ['intent_id'] - The intent ID from Stripe.
582 * @return Order
583 */
584 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $transactionArgs = [])
585 {
586 $transactionUpdateData = array_filter([
587 'vendor_charge_id' => Arr::get($transactionArgs, 'vendor_charge_id', ''),
588 'payment_method' => 'paypal',
589 'status' => Arr::get($transactionArgs, 'status', Status::TRANSACTION_SUCCEEDED),
590 'total' => (int)Arr::get($transactionArgs, 'total', 0),
591 // payment_method_type: this is the intent ID. We may need that later In case we don't have the vendor_charge_id
592 'payment_method_type' => Arr::get($transactionArgs, 'payment_method_type', ''),
593 ]);
594
595 $order = Order::query()->where('id', $transaction->order_id)->first();
596 // in race conditions between webhook and AJAX confirmation
597 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
598 if ($transaction->status === Status::TRANSACTION_SUCCEEDED || $transactionUpdateData['status'] !== Status::TRANSACTION_SUCCEEDED) {
599 if (!$transaction->vendor_charge_id && !empty($transactionUpdateData['vendor_charge_id'])) {
600 $transaction->update(['vendor_charge_id' => $transactionUpdateData['vendor_charge_id']]);
601 }
602 return $order; // already confirmed or not needed to confirm
603 }
604
605 // handle payment source
606 $cardData = Arr::get($transactionArgs, 'payment_source.card', []);
607 if ($cardData) {
608 $transactionUpdateData['card_last_4'] = strlen(Arr::get($cardData, 'last_digits')) > 4 ? substr(Arr::get($cardData, 'last_digits'), -4) : Arr::get($cardData, 'last_digits');
609 $transactionUpdateData['card_brand'] = Arr::get($cardData, 'brand');
610 }
611
612 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
613
614 // A zero-decimal total is stored x100 but charged rounded, so PayPal reports back a
615 // figure up to half a unit away from the stored one. The wire comparison upstream has
616 // already proved this is the same payment. Keep the stored number: it is what the
617 // order's line items sum to, so adopting the rounded one would either strand the order
618 // partially_paid (rounded down) or fake an overpayment (rounded up). Record what
619 // actually moved in meta instead. activateSubscription() already leaves total alone.
620 $reportedTotal = (int)Arr::get($transactionUpdateData, 'total', 0);
621 if ($reportedTotal
622 && $reportedTotal !== (int)$transaction->total
623 && PayPalHelper::currencyDecimals($transaction->currency) === 0
624 && $reportedTotal === PayPalHelper::wireCents($transaction->total, $transaction->currency)
625 ) {
626 unset($transactionUpdateData['total']);
627 $transactionUpdateData['meta']['wire_total'] = $reportedTotal;
628 }
629
630 $transaction->fill($transactionUpdateData);
631 $transaction->save();
632
633 fluent_cart_add_log(__('PayPal Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from PayPal. Transaction ID: ', 'fluent-cart') . Arr::get($transactionArgs, 'vendor_charge_id', ''), 'info', [
634 'module_name' => 'order',
635 'module_id' => $order->id,
636 ]);
637
638 // Maybe we have to save the billing details
639
640 // We are assuming. This is only for one time payment. No subscription or renewal will be here!
641
642 return (new StatusHelper($order))->syncOrderStatuses($transaction);
643 }
644
645
646 // This should be only used from the ajax call for the very first time subscription activation
647 public function activateSubscription($paypalSubscription, OrderTransaction $transaction, $subscriptionModel = null)
648 {
649 $order = $transaction->order;
650
651 if (!$subscriptionModel) {
652 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
653 }
654
655 if (!$subscriptionModel) {
656 return null;
657 }
658
659 if ($order->type !== Status::ORDER_TYPE_RENEWAL && $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
660 return $subscriptionModel;
661 }
662
663 // Verify the PayPal subscription's plan matches the expected plan
664 if ($subscriptionModel->vendor_plan_id) {
665 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
666 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
667 fluent_cart_add_log(
668 __('PayPal Subscription Plan Mismatch', 'fluent-cart'),
669 sprintf(
670 /* translators: %1$s: expected plan ID, %2$s: received plan ID */
671 __('PayPal subscription plan mismatch. Expected: %1$s, Received: %2$s. Subscription not activated.', 'fluent-cart'),
672 $subscriptionModel->vendor_plan_id,
673 $paypalPlanId
674 ),
675 'error',
676 [
677 'module_name' => 'order',
678 'module_id' => $order->id,
679 'log_type' => 'api'
680 ]
681 );
682 return $subscriptionModel; // Do not activate
683 }
684 }
685
686 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time') ?? null;
687 if ($nextBillingDate) {
688 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
689 } else {
690 // calculate the next billing date, as PayPal has not been charged yet
691 $billingIntervalDays = PaymentHelper::getIntervalDays($subscriptionModel->billing_interval) + (int) $subscriptionModel->trial_days;
692 $nextBillingDate = DateTime::gmtNow()->addDays($billingIntervalDays)->format('Y-m-d H:i:s');
693 }
694
695 $subscriptionUpdateData = array_filter([
696 'next_billing_date' => $nextBillingDate,
697 'status' => Status::SUBSCRIPTION_ACTIVE,
698 'vendor_subscription_id' => $paypalSubscription['id'],
699 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
700 'current_payment_method' => 'paypal',
701 ]);
702
703 $lastPaymentAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
704 $lastPaymentCurrency = strtoupper(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.currency_code', ''));
705
706 // A subscription can legitimately be ACTIVE with no initial payment yet — a free
707 // trial, or a future start_time whose first charge PayPal has not run. Only mark the
708 // initial transaction SUCCEEDED (which flips the order to paid and triggers
709 // fulfilment) when PayPal reports a real initial payment whose amount AND currency
710 // match what we expected, or when nothing is owed (total == 0). ACTIVE alone is never
711 // treated as paid: an amount- or currency-mismatched payment leaves the order pending
712 // for the PAYMENT.SALE.COMPLETED webhook to reconcile, so a forced activation can
713 // never deliver a paid product for free.
714 $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
715 || strtoupper($transaction->currency) === $lastPaymentCurrency;
716
717 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
718
719 $initialPaymentVerified = $lastPaymentAmount
720 && $expectedAmount == $lastPaymentAmount
721 && $currencyMatches;
722
723 if ($initialPaymentVerified || $transaction->total == 0) {
724 $transactionUpdateData = array_filter([
725 'order_id' => $order->id,
726 'status' => Status::TRANSACTION_SUCCEEDED,
727 'payment_method' => 'paypal',
728 ]);
729
730 $transaction->fill($transactionUpdateData);
731 $transaction->save();
732 } elseif ($lastPaymentAmount && $transaction->total > 0) {
733 // A payment was reported but its amount or currency does not match the expected
734 // charge — do not mark the order paid; record it for audit (possible tampering).
735 fluent_cart_warning_log(
736 __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
737 sprintf(
738 /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
739 __('Subscription initial payment mismatch. Expected: %1$s %2$s, Received: %3$s %4$s. Order not marked paid; awaiting webhook.', 'fluent-cart'),
740 Helper::toDecimal($expectedAmount),
741 $transaction->currency,
742 Helper::toDecimal($lastPaymentAmount),
743 $lastPaymentCurrency
744 ),
745 [
746 'module_name' => 'order',
747 'module_id' => $order->id,
748 'log_type' => 'api'
749 ]
750 );
751 }
752
753
754 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
755 $subscriptionUpdateData['canceled_at'] = null;
756 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
757 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
758 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
759 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
760 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
761 ]);
762
763 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
764 SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
765 'billing_info' => $billingInfo,
766 'subscription_args' => $subscriptionUpdateData
767 ]);
768 } else {
769 $subscriptionModel->fill($subscriptionUpdateData)->save();
770 $subscriptionModel->updateMeta('active_payment_method', $billingInfo);
771 do_action('fluent_cart/renewal/payment_scheduled', [
772 'order' => $order,
773 'subscription' => $subscriptionModel,
774 ]);
775 }
776
777 } else {
778 // This can be a trialing subscription
779 if ($subscriptionModel->trial_days > 0) {
780 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
781 }
782
783 // Atomic conditional update: only the caller that actually flips status out of a
784 // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
785 // can't both dispatch SubscriptionActivated.
786 $activatedNow = (bool) Subscription::query()
787 ->where('id', $subscriptionModel->id)
788 ->whereNotIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
789 ->update($subscriptionUpdateData);
790
791 $subscriptionModel->fill($subscriptionUpdateData);
792
793 // updateMeta() is check-then-create with no unique (subscription_id, meta_key)
794 // constraint — gate it behind $activatedNow too, else a losing concurrent caller
795 // still inserts a duplicate active_payment_method meta row.
796 if ($activatedNow) {
797 $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
798 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
799 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
800 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
801 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
802 ]));
803
804 if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
805 (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
806 }
807 }
808 }
809
810 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
811 (new StatusHelper($order))->syncOrderStatuses($transaction);
812 } else {
813 fluent_cart_add_log('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.', [
814 'module_name' => 'order',
815 'module_id' => $order->id,
816 ]);
817 if ($subscriptionModel) {
818 $subscriptionModel->addLog('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.');
819 }
820 }
821
822 return $subscriptionModel;
823 }
824
825
826 /**
827 * Cents to a decimal amount rounded to the precision PayPal accepts for
828 * the currency (HUF/JPY/TWD take no decimals). Rounding before the
829 * breakdown arithmetic keeps the parts summing to the total.
830 */
831 private function toDecimal($cents, $currency)
832 {
833 return PayPalHelper::toDecimalAmount($cents, $currency);
834 }
835
836 /**
837 * Persist the vaulted PayPal payment token from a captured order onto the
838 * system subscription — the token future renewal charges read (at fire time)
839 * from active_payment_method. Idempotent per token; shared by the AJAX
840 * confirmation and the PAYMENT.CAPTURE.COMPLETED webhook (whichever lands
841 * first wins).
842 *
843 * When the FIRST (initial) capture of a system subscription carries NO vault
844 * token — vaulting declined or unavailable on the merchant account — the
845 * subscription is demoted to plain manual invoicing immediately: a `system`
846 * subscription without a token would fail every scheduled charge forever.
847 *
848 * @param OrderTransaction $transaction
849 * @param array $paypalOrder The captured Orders-v2 order (full representation).
850 */
851 public function maybePersistVaultToken(OrderTransaction $transaction, $paypalOrder)
852 {
853 if (!$transaction->subscription_id || !is_array($paypalOrder)) {
854 return;
855 }
856
857 /** @var Subscription|null $subscription */
858 $subscription = Subscription::query()->find($transaction->subscription_id);
859
860 if (!$subscription || !$subscription->isSystem()) {
861 return;
862 }
863
864 $vault = Arr::get($paypalOrder, 'payment_source.paypal.attributes.vault', []);
865 $tokenId = Arr::get($vault, 'id', '');
866
867 $existing = $subscription->getMeta('active_payment_method', []) ?: [];
868
869 if ($tokenId) {
870 if (Arr::get($existing, 'vendor_method_id') === $tokenId) {
871 return; // already persisted (webhook/AJAX race)
872 }
873
874 $vaultCustomerId = Arr::get($vault, 'customer.id', '');
875 if ($vaultCustomerId && !$subscription->vendor_customer_id) {
876 $subscription->vendor_customer_id = $vaultCustomerId;
877 $subscription->save();
878 }
879
880 $payerEmail = Arr::get($paypalOrder, 'payment_source.paypal.email_address', '');
881 if (!$payerEmail) {
882 $payerEmail = Arr::get($paypalOrder, 'payer.email_address', '');
883 }
884
885 $payerName = trim(Arr::get($paypalOrder, 'payer.name.given_name', '') . ' ' . Arr::get($paypalOrder, 'payer.name.surname', ''));
886
887 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
888 'email' => $payerEmail,
889 'payer_id' => Arr::get($paypalOrder, 'payer.payer_id', ''),
890 'name' => $payerName,
891 ]);
892 $billingInfo['vendor_method_id'] = $tokenId;
893
894 $subscription->updateMeta('active_payment_method', $billingInfo);
895
896 $subscription->addLog(
897 'PayPal account saved',
898 __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
899 'info'
900 );
901
902 return;
903 }
904
905 // No token on the INITIAL capture and none stored yet — never leave a
906 // system subscription that can never be charged.
907 if ($transaction->order
908 && $transaction->order->type === Status::ORDER_TYPE_SUBSCRIPTION
909 && !Arr::get($existing, 'vendor_method_id')
910 ) {
911 SystemChargeService::demoteToManual(
912 $subscription,
913 __('PayPal did not return a saved payment method for automatic charging.', 'fluent-cart')
914 );
915 }
916 }
917
918 /**
919 * Merchant-initiated off-session charge of a renewal invoice against the
920 * vaulted PayPal token (Orders v2 create with payment_source.paypal.vault_id).
921 * Contract per dev-docs/system-subscriptions/gateway-implementation-guide.md:
922 * true = confirmed through the normal capture path; 'processing' = accepted
923 * but settling (eCheck); WP_Error = definitive failure.
924 */
925 public function chargeVaultedRenewal(PaymentInstance $paymentInstance, $args = [])
926 {
927 $order = $paymentInstance->order;
928 $transaction = $paymentInstance->transaction;
929 $subscription = $paymentInstance->subscription;
930
931 if (!$order || !$transaction || !$subscription) {
932 return new \WP_Error('invalid_instance', __('Renewal invoice is missing its order, transaction, or subscription.', 'fluent-cart'));
933 }
934
935 // Token read AT FIRE TIME — never snapshotted. Both meta shapes accepted.
936 $paymentMethodMeta = $subscription->getMeta('active_payment_method', []) ?: [];
937 $token = Arr::get($paymentMethodMeta, 'vendor_method_id');
938 if (!$token) {
939 $token = Arr::get($paymentMethodMeta, 'details.payment_method_id');
940 }
941
942 if (!$token) {
943 return new \WP_Error('missing_token', __('No saved PayPal payment method is available for this subscription.', 'fluent-cart'));
944 }
945
946 $attempt = max(1, (int) Arr::get($args, 'attempt', 1));
947
948 $purchaseUnit = [
949 'reference_id' => $transaction->uuid,
950 'custom_id' => $transaction->uuid,
951 'amount' => [
952 'currency_code' => strtoupper($transaction->currency),
953 'value' => PayPalHelper::formatAmount((int) $transaction->total, $transaction->currency),
954 ],
955 ];
956
957 $paypalOrder = API::createOrder($purchaseUnit, [
958 'payment_source' => ['paypal' => ['vault_id' => $token]],
959 ], [
960 // One vendor charge per (order, attempt) — a scheduler double-fire
961 // replays the original response instead of charging twice.
962 'PayPal-Request-Id' => 'fct_system_charge_' . $order->uuid . '_' . $attempt,
963 ]);
964
965 if (is_wp_error($paypalOrder)) {
966 return $paypalOrder;
967 }
968
969 return $this->settleVaultChargeResponse($transaction, $paypalOrder);
970 }
971
972 /**
973 * Re-check a processing vault charge (lost webhook / slow eCheck). A transient
974 * API error reports 'processing' — never fail a possibly-settled payment.
975 */
976 public function reconcileVaultedRenewal(PaymentInstance $paymentInstance)
977 {
978 $transaction = $paymentInstance->transaction;
979
980 if (!$transaction) {
981 return new \WP_Error('missing_intent', __('No transaction is recorded for this renewal order.', 'fluent-cart'));
982 }
983
984 // Preferred: the capture id recorded when the charge was accepted.
985 if ($transaction->vendor_charge_id) {
986 $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET');
987
988 if (is_wp_error($capture)) {
989 return 'processing';
990 }
991
992 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
993
994 if ($captureStatus === 'COMPLETED') {
995 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
996 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
997 'status' => Status::TRANSACTION_SUCCEEDED,
998 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
999 'payment_method_type' => 'PayPal',
1000 ]);
1001 return true;
1002 }
1003
1004 if ($captureStatus === 'PENDING') {
1005 return 'processing';
1006 }
1007
1008 return new \WP_Error('charge_failed', sprintf(
1009 /* translators: %1$s: PayPal capture status */
1010 __('The pending PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
1011 $captureStatus !== '' ? $captureStatus : 'unknown'
1012 ));
1013 }
1014
1015 // Fallback: the vault order id stored at charge time.
1016 $paypalOrderId = Arr::get($transaction->meta ?? [], 'paypal_vault_order_id', '');
1017
1018 if (!$paypalOrderId) {
1019 return new \WP_Error('missing_intent', __('No PayPal charge is recorded for this renewal order.', 'fluent-cart'));
1020 }
1021
1022 $paypalOrder = API::verifyPayment($paypalOrderId);
1023
1024 if (is_wp_error($paypalOrder)) {
1025 return 'processing';
1026 }
1027
1028 return $this->settleVaultChargeResponse(OrderTransaction::query()->find($transaction->id), $paypalOrder);
1029 }
1030
1031 public function syncRemoteTransaction(OrderTransaction $transaction)
1032 {
1033 $mode = $transaction->payment_mode ?: '';
1034
1035 $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET', [], $mode);
1036
1037 if (is_wp_error($capture)) {
1038 return $capture;
1039 }
1040
1041 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1042
1043 if ($captureStatus === 'COMPLETED') {
1044 $captureCurrency = strtoupper((string) Arr::get($capture, 'amount.currency_code', ''));
1045 if ($captureCurrency && $transaction->currency && strtoupper($transaction->currency) !== $captureCurrency) {
1046 fluent_cart_warning_log(
1047 __('PayPal Currency Mismatch On Sync', 'fluent-cart'),
1048 sprintf(
1049 /* translators: %1$s: expected currency, %2$s: received currency */
1050 __('Capture currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
1051 $transaction->currency,
1052 $captureCurrency
1053 ),
1054 [
1055 'module_name' => 'order',
1056 'module_id' => $transaction->order_id,
1057 'log_type' => 'api'
1058 ]
1059 );
1060
1061 return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
1062 }
1063
1064 $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0));
1065 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
1066
1067 if ($captureAmount !== $expectedAmount) {
1068 fluent_cart_warning_log(
1069 __('PayPal Amount Mismatch On Sync', 'fluent-cart'),
1070 sprintf(
1071 /* translators: %1$s: expected amount, %2$s: received amount */
1072 __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
1073 Helper::toDecimal($expectedAmount),
1074 Helper::toDecimal($captureAmount)
1075 ),
1076 [
1077 'module_name' => 'order',
1078 'module_id' => $transaction->order_id,
1079 'log_type' => 'api'
1080 ]
1081 );
1082
1083 return new \WP_Error('amount_mismatch', __('The PayPal payment amount does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
1084 }
1085
1086 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1087 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
1088 'status' => Status::TRANSACTION_SUCCEEDED,
1089 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1090 'payment_method_type' => 'PayPal',
1091 ]);
1092
1093 return OrderTransaction::query()->find($transaction->id);
1094 }
1095
1096 if ($captureStatus === 'PENDING') {
1097 return new \WP_Error('still_pending', sprintf(
1098 /* translators: %1$s: PayPal pending hold reason */
1099 __('The payment is still pending at PayPal (reason: %1$s). Please try again later.', 'fluent-cart'),
1100 Arr::get($capture, 'status_details.reason', '') ?: 'unknown'
1101 ));
1102 }
1103
1104 return new \WP_Error('charge_not_completed', sprintf(
1105 /* translators: %1$s: PayPal capture status */
1106 __('The PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
1107 $captureStatus !== '' ? $captureStatus : 'unknown'
1108 ));
1109 }
1110
1111 /**
1112 * Shared outcome derivation for a vault-charged Orders-v2 order: record the
1113 * ids for reconciliation, confirm completed captures through the normal
1114 * capture path, report settling captures as 'processing', everything else as
1115 * a definitive failure with PayPal's reason.
1116 *
1117 * Public so an extension charging a vaulted token outside the renewal engine
1118 * (saved payment methods) settles through this exact contract rather than
1119 * reimplementing it. The PENDING branch in particular is money-critical: a
1120 * settling eCheck is neither paid nor failed, and a duplicate of this logic
1121 * would eventually drift and mis-report one.
1122 *
1123 * @return true|string|\WP_Error true = captured, 'processing' = settling
1124 */
1125 public function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
1126 {
1127 $orderStatus = strtoupper((string) Arr::get($paypalOrder, 'status', ''));
1128 $capture = Arr::get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
1129 $captureId = Arr::get($capture, 'id', '');
1130 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1131
1132 // Persist ids FIRST — the reconciliation loop and webhook dedup key on them.
1133 $transactionMeta = array_merge($transaction->meta ?? [], [
1134 'paypal_vault_order_id' => Arr::get($paypalOrder, 'id', ''),
1135 ]);
1136 $transactionUpdate = ['meta' => $transactionMeta];
1137 if ($captureId && !$transaction->vendor_charge_id) {
1138 $transactionUpdate['vendor_charge_id'] = $captureId;
1139 }
1140 $transaction->update($transactionUpdate);
1141
1142 if ($captureId && $captureStatus === 'COMPLETED') {
1143 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1144 'vendor_charge_id' => $captureId,
1145 'status' => Status::TRANSACTION_SUCCEEDED,
1146 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1147 'payment_method_type' => 'PayPal',
1148 'payment_source' => Arr::get($paypalOrder, 'payment_source', []),
1149 'meta' => ['payer' => Arr::get($paypalOrder, 'payer', [])],
1150 ]);
1151 return true;
1152 }
1153
1154 if ($captureStatus === 'PENDING' || $orderStatus === 'PENDING') {
1155 return 'processing';
1156 }
1157
1158 $reason = Arr::get($capture, 'status_details.reason', '');
1159
1160 if ($reason) {
1161 /* translators: %1$s: PayPal decline reason code */
1162 $message = sprintf(__('Automatic PayPal charge failed: %1$s', 'fluent-cart'), $reason);
1163 } else {
1164 /* translators: %1$s: PayPal order status */
1165 $message = sprintf(__('Automatic PayPal charge could not be completed (status: %1$s).', 'fluent-cart'), $orderStatus !== '' ? $orderStatus : 'unknown');
1166 }
1167
1168 return new \WP_Error('charge_failed', $message);
1169 }
1170
1171 }
1172