PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 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 All 51 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / Processor.php

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

1,202 lines 53.4 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 $brandName = (new PayPalSettingsBase())->getBrandName([
249 'order' => $order,
250 'subscription' => $paymentInstance->subscription,
251 ]);
252
253 if ($brandName !== '') {
254 if (isset($extraBody['payment_source'])) {
255 $extraBody['payment_source']['paypal']['experience_context']['brand_name'] = $brandName;
256 } else {
257 $extraBody['application_context'] = ['shipping_preference' => 'NO_SHIPPING', 'brand_name' => $brandName];
258 }
259 }
260
261 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
262
263 // Vaulting is a convenience; the purchase is the point. A merchant account
264 // not approved for vaulting can reject the order outright because of the
265 // vault attributes, and failing the sale over a save the buyer merely
266 // opted into would be the wrong trade. Retry once without them and let
267 // listeners record that this account cannot vault, so the saving UI can
268 // stop being offered instead of failing silently on every order.
269 //
270 // ONLY for an error that actually implicates the vault attributes. An auth
271 // failure, rate limit, malformed amount or transport error is not evidence
272 // that this account cannot vault: retrying would not fix it, and telling a
273 // listener otherwise would switch saving off for a perfectly capable
274 // account on the strength of an unrelated outage.
275 if (is_wp_error($paypalOrder) && $vaultOnSuccess && self::isVaultRejection($paypalOrder)) {
276 do_action('fluent_cart/payments/paypal_vault_rejected', [
277 'order' => $order,
278 'transaction' => $transaction,
279 'error' => $paypalOrder,
280 ]);
281
282 unset($extraBody['payment_source']['paypal']['attributes']);
283
284 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
285 }
286
287 if (is_wp_error($paypalOrder)) {
288 return $paypalOrder;
289 }
290
291 $paypalOrderId = Arr::get($paypalOrder, 'id');
292
293 $transaction->update([
294 'meta' => array_merge($transaction->meta ?? [], ['paypal_order_id' => $paypalOrderId])
295 ]);
296
297 return [
298 'nextAction' => 'paypal',
299 'actionName' => 'custom',
300 'status' => 'success',
301 'data' => [
302 'order' => [
303 'uuid' => $order->uuid,
304 ],
305 'transaction' => [
306 'uuid' => $transaction->uuid,
307 ]
308 ],
309 'message' => __('Order has been placed successfully', 'fluent-cart'),
310 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
311 'response' => [
312 'paypalOrderId' => $paypalOrderId,
313 ]
314 ];
315 }
316
317 /**
318 * Zero-payable system subscription checkout (free trial): a $0 PayPal order
319 * is invalid, so the buyer's PayPal account is vaulted via a Vault v3 setup
320 * token; confirmVaultSetup() exchanges it, completes the $0 order, and the
321 * trial-end invoice is charged off-session like any other system renewal.
322 * The save agreement is carried by PayPal's own approval popup; the checkout
323 * page shows the informational disclosure next to the buttons.
324 */
325 public function handleSetupOnlyPayment(PaymentInstance $paymentInstance)
326 {
327 $order = $paymentInstance->order;
328 $transaction = $paymentInstance->transaction;
329
330 $experienceContext = [
331 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
332 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
333 'shipping_preference' => 'NO_SHIPPING',
334 ];
335
336 $brandName = (new PayPalSettingsBase())->getBrandName([
337 'order' => $order,
338 'subscription' => $paymentInstance->subscription,
339 ]);
340
341 if ($brandName !== '') {
342 $experienceContext['brand_name'] = $brandName;
343 }
344
345 $setupToken = API::makeRequest('vault/setup-tokens', 'v3', 'POST', [
346 'payment_source' => [
347 'paypal' => [
348 'usage_type' => 'MERCHANT',
349 'customer_type' => 'CONSUMER',
350 'experience_context' => $experienceContext,
351 ],
352 ],
353 ]);
354
355 if (is_wp_error($setupToken)) {
356 return $setupToken;
357 }
358
359 $setupTokenId = Arr::get($setupToken, 'id');
360
361 if (!$setupTokenId) {
362 return new \WP_Error('setup_token_failed', __('PayPal did not return a setup token.', 'fluent-cart'));
363 }
364
365 // confirmVaultSetup() binds the buyer's approval to this transaction by
366 // this id; the write takes the same lock as confirmation so a
367 // replacement can never interleave with an in-flight confirm.
368 if (!self::acquireVaultTransactionLock($transaction->uuid)) {
369 return new \WP_Error('setup_in_progress', __('Another payment confirmation is in progress. Please try again.', 'fluent-cart'));
370 }
371
372 try {
373 $transaction->update([
374 'meta' => array_merge($transaction->meta ?? [], ['paypal_setup_token_id' => $setupTokenId])
375 ]);
376 } finally {
377 self::releaseVaultTransactionLock($transaction->uuid);
378 }
379
380 return [
381 'nextAction' => 'paypal',
382 'actionName' => 'custom',
383 'status' => 'success',
384 'data' => [
385 'order' => [
386 'uuid' => $order->uuid,
387 ],
388 'transaction' => [
389 'uuid' => $transaction->uuid,
390 ]
391 ],
392 'message' => __('Order has been placed successfully', 'fluent-cart'),
393 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
394 'response' => [
395 'setupTokenId' => $setupTokenId,
396 ]
397 ];
398 }
399
400 /**
401 * Vault-flow lock, keyed on the transaction uuid — shared by the setup-token
402 * binding write and the confirmation endpoint so token replacement and
403 * confirmation of one transaction always serialize.
404 */
405 public static function acquireVaultTransactionLock($transactionUuid)
406 {
407 global $wpdb;
408
409 $result = $wpdb->get_var($wpdb->prepare(
410 'SELECT GET_LOCK(%s, %d)',
411 'fluent_cart_paypal_vault_' . md5($transactionUuid),
412 10
413 ));
414
415 return (string) $result === '1';
416 }
417
418 public static function releaseVaultTransactionLock($transactionUuid)
419 {
420 global $wpdb;
421
422 $wpdb->get_var($wpdb->prepare(
423 'SELECT RELEASE_LOCK(%s)',
424 'fluent_cart_paypal_vault_' . md5($transactionUuid)
425 ));
426 }
427
428 /**
429 * Exchange an approved setup token for a durable payment token, persist it
430 * on the system subscription, and complete the $0 order — the trial then
431 * activates through the normal status-sync path.
432 *
433 * @param OrderTransaction $transaction
434 * @param string $setupTokenId
435 * @return true|\WP_Error
436 */
437 public function confirmVaultSetup(OrderTransaction $transaction, $setupTokenId)
438 {
439 // A prior confirmation may have died between marking the transaction
440 // succeeded and syncing the order — always re-run the idempotent sync.
441 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
442 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
443 return true;
444 }
445
446 /** @var Subscription|null $subscription */
447 $subscription = Subscription::query()->find($transaction->subscription_id);
448
449 if (!$subscription || !$subscription->isSystem()) {
450 return new \WP_Error('invalid_subscription', __('No auto-charged subscription is attached to this transaction.', 'fluent-cart'));
451 }
452
453 // Keyed on the setup token: a double-fired confirmation replays the
454 // original payment token instead of vaulting twice.
455 $paymentToken = API::makeRequest('vault/payment-tokens', 'v3', 'POST', [
456 'payment_source' => [
457 'token' => [
458 'id' => $setupTokenId,
459 'type' => 'SETUP_TOKEN',
460 ],
461 ],
462 ], '', [
463 'PayPal-Request-Id' => 'fct_paypal_pt_' . md5($setupTokenId),
464 ]);
465
466 if (is_wp_error($paymentToken)) {
467 return $paymentToken;
468 }
469
470 $tokenId = Arr::get($paymentToken, 'id');
471
472 if (!$tokenId) {
473 return new \WP_Error('vault_failed', __('PayPal did not return a saved payment method.', 'fluent-cart'));
474 }
475
476 $vaultCustomerId = Arr::get($paymentToken, 'customer.id', '');
477 if ($vaultCustomerId && !$subscription->vendor_customer_id) {
478 $subscription->vendor_customer_id = $vaultCustomerId;
479 $subscription->save();
480 }
481
482 $paypalSource = Arr::get($paymentToken, 'payment_source.paypal', []);
483 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
484 'email' => Arr::get($paypalSource, 'email_address', ''),
485 'payer_id' => Arr::get($paypalSource, 'account_id', ''),
486 'name' => trim(Arr::get($paypalSource, 'name.given_name', '') . ' ' . Arr::get($paypalSource, 'name.surname', '')),
487 ]);
488 $billingInfo['vendor_method_id'] = $tokenId;
489
490 $subscription->updateMeta('active_payment_method', $billingInfo);
491
492 $subscription->addLog(
493 'PayPal account saved',
494 __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
495 'info'
496 );
497
498 $transaction->fill([
499 'status' => Status::TRANSACTION_SUCCEEDED,
500 'payment_method' => 'paypal',
501 ]);
502 $transaction->save();
503
504 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
505
506 return true;
507 }
508
509 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
510 {
511 $orderType = $paymentInstance->order->type;
512 $subscription = $paymentInstance->subscription;
513 $feeTotal = $orderType !== 'renewal' ? (int)$paymentInstance->order->fee_total : 0;
514 $initialAmount = (int)$subscription->signup_fee + $paymentInstance->getExtraAddonAmount() + $feeTotal;
515 $status = Status::SUBSCRIPTION_INTENDED;
516
517 if ($orderType == 'renewal') {
518 $requiredBillTimes = $subscription->getRequiredBillTimes();
519
520 if ($requiredBillTimes === -1) {
521 return new \WP_Error('already_completed', __('Invalid bill times for the subscription.', 'fluent-cart'));
522 }
523
524 $data = [
525 'order_id' => $subscription->parent_order_id,
526 'product_id' => $subscription->product_id,
527 'variation_id' => $subscription->variation_id,
528 'trial_days' => $subscription->getReactivationTrialDays(), // trial days for reactivation
529 'billing_interval' => $subscription->billing_interval,
530 'currency' => $paymentInstance->order->currency,
531 'interval_count' => 1, // 1
532 'recurring_amount' => $subscription->getCurrentRenewalAmount(), // default recurring total in cents
533 'signup_fee' => 0, // default setup fee in cents ($0.00)
534 'bill_times' => $requiredBillTimes, // 0 for unlimited
535 ];
536 $status = $subscription->status;
537 } else {
538 $data = [
539 'order_id' => $subscription->parent_order_id,
540 'product_id' => $subscription->product_id,
541 'variation_id' => $subscription->variation_id,
542 'trial_days' => $subscription->trial_days,
543 'billing_interval' => $subscription->billing_interval,
544 'currency' => $paymentInstance->order->currency,
545 'interval_count' => 1, // 1
546 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
547 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
548 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
549 ];
550
551 }
552
553 $paypalPlan = PayPalHelper::getPayPalPlan($data);
554
555 if (is_wp_error($paypalPlan)) {
556 return $paypalPlan;
557 }
558
559 $subscriptionUpdateFields = [
560 'status' => $status,
561 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
562 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
563 ];
564
565 $subscription->update($subscriptionUpdateFields);
566
567 if ($orderType == 'renewal' && !empty($data['trial_days'])) {
568 $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
569 }
570
571 return [
572 'status' => 'success',
573 'nextAction' => 'paypal',
574 'actionName' => 'custom',
575 'message' => __('Order has been placed successfully', 'fluent-cart'),
576 'data' => [
577 'order' => [
578 'uuid' => $paymentInstance->order->uuid,
579 ],
580 'transaction' => [
581 'uuid' => $paymentInstance->transaction->uuid,
582 ],
583 'subscription' => [
584 'uuid' => $subscription->uuid,
585 ]
586 ],
587 'response' => [
588 'planId' => Arr::get($paypalPlan, 'id'),
589 'brandName' => (new PayPalSettingsBase())->getBrandName([
590 'order' => $paymentInstance->order,
591 'subscription' => $subscription,
592 ]),
593 ]
594 ];
595 }
596
597 /**
598 * Confirm payment success
599 * Currently used by:
600 * @param OrderTransaction $transaction
601 * @param array $args
602 * @param array $transactionArgs
603 * string vendor_charge_id - The intent_id from paypal
604 * string total - The amount charged in cents
605 * string status - The status of the transaction ('succeeded', 'pending', etc.))
606 * array payer - The payer information from PayPal.
607 * array payment_source - The payment source information from PayPal.
608 *
609 * @param string $args ['intent_id'] - The intent ID from Stripe.
610 * @return Order
611 */
612 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $transactionArgs = [])
613 {
614 $transactionUpdateData = array_filter([
615 'vendor_charge_id' => Arr::get($transactionArgs, 'vendor_charge_id', ''),
616 'payment_method' => 'paypal',
617 'status' => Arr::get($transactionArgs, 'status', Status::TRANSACTION_SUCCEEDED),
618 'total' => (int)Arr::get($transactionArgs, 'total', 0),
619 // payment_method_type: this is the intent ID. We may need that later In case we don't have the vendor_charge_id
620 'payment_method_type' => Arr::get($transactionArgs, 'payment_method_type', ''),
621 ]);
622
623 $order = Order::query()->where('id', $transaction->order_id)->first();
624 // in race conditions between webhook and AJAX confirmation
625 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
626 if ($transaction->status === Status::TRANSACTION_SUCCEEDED || $transactionUpdateData['status'] !== Status::TRANSACTION_SUCCEEDED) {
627 if (!$transaction->vendor_charge_id && !empty($transactionUpdateData['vendor_charge_id'])) {
628 $transaction->update(['vendor_charge_id' => $transactionUpdateData['vendor_charge_id']]);
629 }
630 return $order; // already confirmed or not needed to confirm
631 }
632
633 // handle payment source
634 $cardData = Arr::get($transactionArgs, 'payment_source.card', []);
635 if ($cardData) {
636 $transactionUpdateData['card_last_4'] = strlen(Arr::get($cardData, 'last_digits')) > 4 ? substr(Arr::get($cardData, 'last_digits'), -4) : Arr::get($cardData, 'last_digits');
637 $transactionUpdateData['card_brand'] = Arr::get($cardData, 'brand');
638 }
639
640 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
641
642 // A zero-decimal total is stored x100 but charged rounded, so PayPal reports back a
643 // figure up to half a unit away from the stored one. The wire comparison upstream has
644 // already proved this is the same payment. Keep the stored number: it is what the
645 // order's line items sum to, so adopting the rounded one would either strand the order
646 // partially_paid (rounded down) or fake an overpayment (rounded up). Record what
647 // actually moved in meta instead. activateSubscription() already leaves total alone.
648 $reportedTotal = (int)Arr::get($transactionUpdateData, 'total', 0);
649 if ($reportedTotal
650 && $reportedTotal !== (int)$transaction->total
651 && PayPalHelper::currencyDecimals($transaction->currency) === 0
652 && $reportedTotal === PayPalHelper::wireCents($transaction->total, $transaction->currency)
653 ) {
654 unset($transactionUpdateData['total']);
655 $transactionUpdateData['meta']['wire_total'] = $reportedTotal;
656 }
657
658 $transaction->fill($transactionUpdateData);
659 $transaction->save();
660
661 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', [
662 'module_name' => 'order',
663 'module_id' => $order->id,
664 ]);
665
666 // Maybe we have to save the billing details
667
668 // We are assuming. This is only for one time payment. No subscription or renewal will be here!
669
670 return (new StatusHelper($order))->syncOrderStatuses($transaction);
671 }
672
673
674 // This should be only used from the ajax call for the very first time subscription activation
675 public function activateSubscription($paypalSubscription, OrderTransaction $transaction, $subscriptionModel = null)
676 {
677 $order = $transaction->order;
678
679 if (!$subscriptionModel) {
680 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
681 }
682
683 if (!$subscriptionModel) {
684 return null;
685 }
686
687 if ($order->type !== Status::ORDER_TYPE_RENEWAL && $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
688 return $subscriptionModel;
689 }
690
691 // Verify the PayPal subscription's plan matches the expected plan
692 if ($subscriptionModel->vendor_plan_id) {
693 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
694 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
695 fluent_cart_add_log(
696 __('PayPal Subscription Plan Mismatch', 'fluent-cart'),
697 sprintf(
698 /* translators: %1$s: expected plan ID, %2$s: received plan ID */
699 __('PayPal subscription plan mismatch. Expected: %1$s, Received: %2$s. Subscription not activated.', 'fluent-cart'),
700 $subscriptionModel->vendor_plan_id,
701 $paypalPlanId
702 ),
703 'error',
704 [
705 'module_name' => 'order',
706 'module_id' => $order->id,
707 'log_type' => 'api'
708 ]
709 );
710 return $subscriptionModel; // Do not activate
711 }
712 }
713
714 $nextBillingDate = Arr::get($paypalSubscription, 'billing_info.next_billing_time') ?? null;
715 if ($nextBillingDate) {
716 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($nextBillingDate));
717 } else {
718 // calculate the next billing date, as PayPal has not been charged yet
719 $billingIntervalDays = PaymentHelper::getIntervalDays($subscriptionModel->billing_interval) + (int) $subscriptionModel->trial_days;
720 $nextBillingDate = DateTime::gmtNow()->addDays($billingIntervalDays)->format('Y-m-d H:i:s');
721 }
722
723 $subscriptionUpdateData = array_filter([
724 'next_billing_date' => $nextBillingDate,
725 'status' => Status::SUBSCRIPTION_ACTIVE,
726 'vendor_subscription_id' => $paypalSubscription['id'],
727 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
728 'current_payment_method' => 'paypal',
729 ]);
730
731 $lastPaymentAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
732 $lastPaymentCurrency = strtoupper(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.currency_code', ''));
733
734 // A subscription can legitimately be ACTIVE with no initial payment yet — a free
735 // trial, or a future start_time whose first charge PayPal has not run. Only mark the
736 // initial transaction SUCCEEDED (which flips the order to paid and triggers
737 // fulfilment) when PayPal reports a real initial payment whose amount AND currency
738 // match what we expected, or when nothing is owed (total == 0). ACTIVE alone is never
739 // treated as paid: an amount- or currency-mismatched payment leaves the order pending
740 // for the PAYMENT.SALE.COMPLETED webhook to reconcile, so a forced activation can
741 // never deliver a paid product for free.
742 $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
743 || strtoupper($transaction->currency) === $lastPaymentCurrency;
744
745 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
746
747 $initialPaymentVerified = $lastPaymentAmount
748 && $expectedAmount == $lastPaymentAmount
749 && $currencyMatches;
750
751 if ($initialPaymentVerified || $transaction->total == 0) {
752 $transactionUpdateData = array_filter([
753 'order_id' => $order->id,
754 'status' => Status::TRANSACTION_SUCCEEDED,
755 'payment_method' => 'paypal',
756 ]);
757
758 $transaction->fill($transactionUpdateData);
759 $transaction->save();
760 } elseif ($lastPaymentAmount && $transaction->total > 0) {
761 // A payment was reported but its amount or currency does not match the expected
762 // charge — do not mark the order paid; record it for audit (possible tampering).
763 fluent_cart_warning_log(
764 __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
765 sprintf(
766 /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
767 __('Subscription initial payment mismatch. Expected: %1$s %2$s, Received: %3$s %4$s. Order not marked paid; awaiting webhook.', 'fluent-cart'),
768 Helper::toDecimal($expectedAmount),
769 $transaction->currency,
770 Helper::toDecimal($lastPaymentAmount),
771 $lastPaymentCurrency
772 ),
773 [
774 'module_name' => 'order',
775 'module_id' => $order->id,
776 'log_type' => 'api'
777 ]
778 );
779 }
780
781
782 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
783 $subscriptionUpdateData['canceled_at'] = null;
784 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
785 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
786 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
787 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
788 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
789 ]);
790
791 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
792 SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
793 'billing_info' => $billingInfo,
794 'subscription_args' => $subscriptionUpdateData
795 ]);
796 } else {
797 $subscriptionModel->fill($subscriptionUpdateData)->save();
798 $subscriptionModel->updateMeta('active_payment_method', $billingInfo);
799 do_action('fluent_cart/renewal/payment_scheduled', [
800 'order' => $order,
801 'subscription' => $subscriptionModel,
802 ]);
803 }
804
805 } else {
806 // This can be a trialing subscription
807 if ($subscriptionModel->trial_days > 0) {
808 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
809 }
810
811 $subscriptionUpdateData['bill_count'] = $subscriptionModel->calculateBillCount();
812
813 // Atomic conditional update: only the caller that actually flips status out of a
814 // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
815 // can't both dispatch SubscriptionActivated.
816 $activatedNow = (bool) Subscription::query()
817 ->where('id', $subscriptionModel->id)
818 ->whereNotIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
819 ->update($subscriptionUpdateData);
820
821 $subscriptionModel->fill($subscriptionUpdateData);
822
823 // updateMeta() is check-then-create with no unique (subscription_id, meta_key)
824 // constraint — gate it behind $activatedNow too, else a losing concurrent caller
825 // still inserts a duplicate active_payment_method meta row.
826 if ($activatedNow) {
827 $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
828 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
829 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
830 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
831 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
832 ]));
833
834 if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
835 (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
836 }
837 }
838 }
839
840 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
841 (new StatusHelper($order))->syncOrderStatuses($transaction);
842 } else {
843 fluent_cart_add_log('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.', [
844 'module_name' => 'order',
845 'module_id' => $order->id,
846 ]);
847 if ($subscriptionModel) {
848 $subscriptionModel->addLog('PayPal Subscription Activated', 'Subscription activated, transaction & order statuses will be synced on webhook receive.');
849 }
850 }
851
852 return $subscriptionModel;
853 }
854
855
856 /**
857 * Cents to a decimal amount rounded to the precision PayPal accepts for
858 * the currency (HUF/JPY/TWD take no decimals). Rounding before the
859 * breakdown arithmetic keeps the parts summing to the total.
860 */
861 private function toDecimal($cents, $currency)
862 {
863 return PayPalHelper::toDecimalAmount($cents, $currency);
864 }
865
866 /**
867 * Persist the vaulted PayPal payment token from a captured order onto the
868 * system subscription — the token future renewal charges read (at fire time)
869 * from active_payment_method. Idempotent per token; shared by the AJAX
870 * confirmation and the PAYMENT.CAPTURE.COMPLETED webhook (whichever lands
871 * first wins).
872 *
873 * When the FIRST (initial) capture of a system subscription carries NO vault
874 * token — vaulting declined or unavailable on the merchant account — the
875 * subscription is demoted to plain manual invoicing immediately: a `system`
876 * subscription without a token would fail every scheduled charge forever.
877 *
878 * @param OrderTransaction $transaction
879 * @param array $paypalOrder The captured Orders-v2 order (full representation).
880 */
881 public function maybePersistVaultToken(OrderTransaction $transaction, $paypalOrder)
882 {
883 if (!$transaction->subscription_id || !is_array($paypalOrder)) {
884 return;
885 }
886
887 /** @var Subscription|null $subscription */
888 $subscription = Subscription::query()->find($transaction->subscription_id);
889
890 if (!$subscription || !$subscription->isSystem()) {
891 return;
892 }
893
894 $vault = Arr::get($paypalOrder, 'payment_source.paypal.attributes.vault', []);
895 $tokenId = Arr::get($vault, 'id', '');
896
897 $existing = $subscription->getMeta('active_payment_method', []) ?: [];
898
899 if ($tokenId) {
900 if (Arr::get($existing, 'vendor_method_id') === $tokenId) {
901 return; // already persisted (webhook/AJAX race)
902 }
903
904 $vaultCustomerId = Arr::get($vault, 'customer.id', '');
905 if ($vaultCustomerId && !$subscription->vendor_customer_id) {
906 $subscription->vendor_customer_id = $vaultCustomerId;
907 $subscription->save();
908 }
909
910 $payerEmail = Arr::get($paypalOrder, 'payment_source.paypal.email_address', '');
911 if (!$payerEmail) {
912 $payerEmail = Arr::get($paypalOrder, 'payer.email_address', '');
913 }
914
915 $payerName = trim(Arr::get($paypalOrder, 'payer.name.given_name', '') . ' ' . Arr::get($paypalOrder, 'payer.name.surname', ''));
916
917 $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
918 'email' => $payerEmail,
919 'payer_id' => Arr::get($paypalOrder, 'payer.payer_id', ''),
920 'name' => $payerName,
921 ]);
922 $billingInfo['vendor_method_id'] = $tokenId;
923
924 $subscription->updateMeta('active_payment_method', $billingInfo);
925
926 $subscription->addLog(
927 'PayPal account saved',
928 __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
929 'info'
930 );
931
932 return;
933 }
934
935 // No token on the INITIAL capture and none stored yet — never leave a
936 // system subscription that can never be charged.
937 if ($transaction->order
938 && $transaction->order->type === Status::ORDER_TYPE_SUBSCRIPTION
939 && !Arr::get($existing, 'vendor_method_id')
940 ) {
941 SystemChargeService::demoteToManual(
942 $subscription,
943 __('PayPal did not return a saved payment method for automatic charging.', 'fluent-cart')
944 );
945 }
946 }
947
948 /**
949 * Merchant-initiated off-session charge of a renewal invoice against the
950 * vaulted PayPal token (Orders v2 create with payment_source.paypal.vault_id).
951 * Contract per dev-docs/system-subscriptions/gateway-implementation-guide.md:
952 * true = confirmed through the normal capture path; 'processing' = accepted
953 * but settling (eCheck); WP_Error = definitive failure.
954 */
955 public function chargeVaultedRenewal(PaymentInstance $paymentInstance, $args = [])
956 {
957 $order = $paymentInstance->order;
958 $transaction = $paymentInstance->transaction;
959 $subscription = $paymentInstance->subscription;
960
961 if (!$order || !$transaction || !$subscription) {
962 return new \WP_Error('invalid_instance', __('Renewal invoice is missing its order, transaction, or subscription.', 'fluent-cart'));
963 }
964
965 // Token read AT FIRE TIME — never snapshotted. Both meta shapes accepted.
966 $paymentMethodMeta = $subscription->getMeta('active_payment_method', []) ?: [];
967 $token = Arr::get($paymentMethodMeta, 'vendor_method_id');
968 if (!$token) {
969 $token = Arr::get($paymentMethodMeta, 'details.payment_method_id');
970 }
971
972 if (!$token) {
973 return new \WP_Error('missing_token', __('No saved PayPal payment method is available for this subscription.', 'fluent-cart'));
974 }
975
976 $attempt = max(1, (int) Arr::get($args, 'attempt', 1));
977
978 $purchaseUnit = [
979 'reference_id' => $transaction->uuid,
980 'custom_id' => $transaction->uuid,
981 'amount' => [
982 'currency_code' => strtoupper($transaction->currency),
983 'value' => PayPalHelper::formatAmount((int) $transaction->total, $transaction->currency),
984 ],
985 ];
986
987 $paypalOrder = API::createOrder($purchaseUnit, [
988 'payment_source' => ['paypal' => ['vault_id' => $token]],
989 ], [
990 // One vendor charge per (order, attempt) — a scheduler double-fire
991 // replays the original response instead of charging twice.
992 'PayPal-Request-Id' => 'fct_system_charge_' . $order->uuid . '_' . $attempt,
993 ]);
994
995 if (is_wp_error($paypalOrder)) {
996 return $paypalOrder;
997 }
998
999 return $this->settleVaultChargeResponse($transaction, $paypalOrder);
1000 }
1001
1002 /**
1003 * Re-check a processing vault charge (lost webhook / slow eCheck). A transient
1004 * API error reports 'processing' — never fail a possibly-settled payment.
1005 */
1006 public function reconcileVaultedRenewal(PaymentInstance $paymentInstance)
1007 {
1008 $transaction = $paymentInstance->transaction;
1009
1010 if (!$transaction) {
1011 return new \WP_Error('missing_intent', __('No transaction is recorded for this renewal order.', 'fluent-cart'));
1012 }
1013
1014 // Preferred: the capture id recorded when the charge was accepted.
1015 if ($transaction->vendor_charge_id) {
1016 $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET');
1017
1018 if (is_wp_error($capture)) {
1019 return 'processing';
1020 }
1021
1022 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1023
1024 if ($captureStatus === 'COMPLETED') {
1025 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1026 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
1027 'status' => Status::TRANSACTION_SUCCEEDED,
1028 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1029 'payment_method_type' => 'PayPal',
1030 ]);
1031 return true;
1032 }
1033
1034 if ($captureStatus === 'PENDING') {
1035 return 'processing';
1036 }
1037
1038 return new \WP_Error('charge_failed', sprintf(
1039 /* translators: %1$s: PayPal capture status */
1040 __('The pending PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
1041 $captureStatus !== '' ? $captureStatus : 'unknown'
1042 ));
1043 }
1044
1045 // Fallback: the vault order id stored at charge time.
1046 $paypalOrderId = Arr::get($transaction->meta ?? [], 'paypal_vault_order_id', '');
1047
1048 if (!$paypalOrderId) {
1049 return new \WP_Error('missing_intent', __('No PayPal charge is recorded for this renewal order.', 'fluent-cart'));
1050 }
1051
1052 $paypalOrder = API::verifyPayment($paypalOrderId);
1053
1054 if (is_wp_error($paypalOrder)) {
1055 return 'processing';
1056 }
1057
1058 return $this->settleVaultChargeResponse(OrderTransaction::query()->find($transaction->id), $paypalOrder);
1059 }
1060
1061 public function syncRemoteTransaction(OrderTransaction $transaction)
1062 {
1063 $mode = $transaction->payment_mode ?: '';
1064
1065 $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET', [], $mode);
1066
1067 if (is_wp_error($capture)) {
1068 return $capture;
1069 }
1070
1071 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1072
1073 if ($captureStatus === 'COMPLETED') {
1074 $captureCurrency = strtoupper((string) Arr::get($capture, 'amount.currency_code', ''));
1075 if ($captureCurrency && $transaction->currency && strtoupper($transaction->currency) !== $captureCurrency) {
1076 fluent_cart_warning_log(
1077 __('PayPal Currency Mismatch On Sync', 'fluent-cart'),
1078 sprintf(
1079 /* translators: %1$s: expected currency, %2$s: received currency */
1080 __('Capture currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
1081 $transaction->currency,
1082 $captureCurrency
1083 ),
1084 [
1085 'module_name' => 'order',
1086 'module_id' => $transaction->order_id,
1087 'log_type' => 'api'
1088 ]
1089 );
1090
1091 return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
1092 }
1093
1094 $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0));
1095 $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
1096
1097 if ($captureAmount !== $expectedAmount) {
1098 fluent_cart_warning_log(
1099 __('PayPal Amount Mismatch On Sync', 'fluent-cart'),
1100 sprintf(
1101 /* translators: %1$s: expected amount, %2$s: received amount */
1102 __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
1103 Helper::toDecimal($expectedAmount),
1104 Helper::toDecimal($captureAmount)
1105 ),
1106 [
1107 'module_name' => 'order',
1108 'module_id' => $transaction->order_id,
1109 'log_type' => 'api'
1110 ]
1111 );
1112
1113 return new \WP_Error('amount_mismatch', __('The PayPal payment amount does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
1114 }
1115
1116 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1117 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
1118 'status' => Status::TRANSACTION_SUCCEEDED,
1119 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1120 'payment_method_type' => 'PayPal',
1121 ]);
1122
1123 return OrderTransaction::query()->find($transaction->id);
1124 }
1125
1126 if ($captureStatus === 'PENDING') {
1127 return new \WP_Error('still_pending', sprintf(
1128 /* translators: %1$s: PayPal pending hold reason */
1129 __('The payment is still pending at PayPal (reason: %1$s). Please try again later.', 'fluent-cart'),
1130 Arr::get($capture, 'status_details.reason', '') ?: 'unknown'
1131 ));
1132 }
1133
1134 return new \WP_Error('charge_not_completed', sprintf(
1135 /* translators: %1$s: PayPal capture status */
1136 __('The PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
1137 $captureStatus !== '' ? $captureStatus : 'unknown'
1138 ));
1139 }
1140
1141 /**
1142 * Shared outcome derivation for a vault-charged Orders-v2 order: record the
1143 * ids for reconciliation, confirm completed captures through the normal
1144 * capture path, report settling captures as 'processing', everything else as
1145 * a definitive failure with PayPal's reason.
1146 *
1147 * Public so an extension charging a vaulted token outside the renewal engine
1148 * (saved payment methods) settles through this exact contract rather than
1149 * reimplementing it. The PENDING branch in particular is money-critical: a
1150 * settling eCheck is neither paid nor failed, and a duplicate of this logic
1151 * would eventually drift and mis-report one.
1152 *
1153 * @return true|string|\WP_Error true = captured, 'processing' = settling
1154 */
1155 public function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
1156 {
1157 $orderStatus = strtoupper((string) Arr::get($paypalOrder, 'status', ''));
1158 $capture = Arr::get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
1159 $captureId = Arr::get($capture, 'id', '');
1160 $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1161
1162 // Persist ids FIRST — the reconciliation loop and webhook dedup key on them.
1163 $transactionMeta = array_merge($transaction->meta ?? [], [
1164 'paypal_vault_order_id' => Arr::get($paypalOrder, 'id', ''),
1165 ]);
1166 $transactionUpdate = ['meta' => $transactionMeta];
1167 if ($captureId && !$transaction->vendor_charge_id) {
1168 $transactionUpdate['vendor_charge_id'] = $captureId;
1169 }
1170 $transaction->update($transactionUpdate);
1171
1172 if ($captureId && $captureStatus === 'COMPLETED') {
1173 $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1174 'vendor_charge_id' => $captureId,
1175 'status' => Status::TRANSACTION_SUCCEEDED,
1176 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1177 'payment_method_type' => 'PayPal',
1178 'payment_source' => Arr::get($paypalOrder, 'payment_source', []),
1179 'meta' => ['payer' => Arr::get($paypalOrder, 'payer', [])],
1180 ]);
1181 return true;
1182 }
1183
1184 if ($captureStatus === 'PENDING' || $orderStatus === 'PENDING') {
1185 return 'processing';
1186 }
1187
1188 $reason = Arr::get($capture, 'status_details.reason', '');
1189
1190 if ($reason) {
1191 /* translators: %1$s: PayPal decline reason code */
1192 $message = sprintf(__('Automatic PayPal charge failed: %1$s', 'fluent-cart'), $reason);
1193 } else {
1194 /* translators: %1$s: PayPal order status */
1195 $message = sprintf(__('Automatic PayPal charge could not be completed (status: %1$s).', 'fluent-cart'), $orderStatus !== '' ? $orderStatus : 'unknown');
1196 }
1197
1198 return new \WP_Error('charge_failed', $message);
1199 }
1200
1201 }
1202