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
← All changes | app/Modules/PaymentMethods/PayPalGateway/Processor.php +804 -55 1.5.2 → 1.7.1 View file →
@@ -10,8 +10,9 @@
10 10 use FluentCart\App\Models\Order;
11 11 use FluentCart\App\Models\OrderTransaction;
12 12 use FluentCart\App\Models\Subscription;
13 13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 +use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
14 15 use FluentCart\App\Services\DateTime\DateTime;
15 16 use FluentCart\App\Services\Payments\PaymentHelper;
16 17 use FluentCart\App\Services\Payments\PaymentInstance;
17 18 use FluentCart\Framework\Support\Arr;
@@ -17,19 +18,76 @@
17 18 use FluentCart\Framework\Support\Arr;
18 19
19 20 class Processor
20 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 +
21 78 public function handleSinglePayment(PaymentInstance $paymentInstance, $args = [])
22 79 {
23 80 $transaction = $paymentInstance->transaction;
24 81 $order = $paymentInstance->order;
25 82
83 + $currency = $transaction->currency;
26 84 $itemsSubTotal = 0;
27 85 $formattedItems = [];
28 86
29 87 foreach ($order->order_items as $item) {
30 88 $quantity = $item->quantity ?? 1;
31 - $perQuantity = $this->toDecimal($item->line_total / $quantity);
89 + $perQuantity = $this->toDecimal($item->line_total / $quantity, $currency);
32 90 $title = $item->post_title . ' ' . $item->title;
33 91
34 92 $formattedItems[] = [
35 93 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
@@ -34,10 +92,10 @@
34 92 $formattedItems[] = [
35 93 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
36 94 'description' => strlen($title) > 4000 ? substr($title, 0, 3997) . '...' : $title,
37 95 'unit_amount' => [
38 - 'currency_code' => $transaction->currency,
39 - 'value' => $perQuantity,
96 + 'currency_code' => $currency,
97 + 'value' => PayPalHelper::formatDecimalAmount($perQuantity, $currency),
40 98 ],
41 99 'quantity' => $quantity,
42 100 ];
43 101
@@ -43,9 +101,9 @@
43 101
44 102 $itemsSubTotal += $perQuantity * $quantity;
45 103 }
46 104
47 - $chargingAmount = $this->toDecimal($transaction->total);
105 + $chargingAmount = $this->toDecimal($transaction->total, $currency);
48 106 $pushedTotal = $itemsSubTotal;
49 107
50 108
51 109 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
@@ -51,14 +109,14 @@
51 109 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
52 110 $purchaseUnits = [
53 111 'reference_id' => $transaction->uuid, // This is the order UUID
54 112 'amount' => [ // https://developer.paypal.com/docs/api/orders/v2/#definition-amount_breakdown
55 - 'currency_code' => $transaction->currency,
56 - 'value' => $chargingAmount,
113 + 'currency_code' => $currency,
114 + 'value' => PayPalHelper::formatDecimalAmount($chargingAmount, $currency),
57 115 'breakdown' => [
58 116 'item_total' => [
59 - 'currency_code' => $transaction->currency,
60 - 'value' => number_format($itemsSubTotal, 2, '.', ''),
117 + 'currency_code' => $currency,
118 + 'value' => PayPalHelper::formatDecimalAmount($itemsSubTotal, $currency),
61 119 ]
62 120 ]
63 121 ],
64 122 'items' => $formattedItems
@@ -75,12 +133,12 @@
75 133 }
76 134 }
77 135
78 136 if ($order->shipping_total > 0) {
79 - $shippingAmount = $this->toDecimal($order->shipping_total);
137 + $shippingAmount = $this->toDecimal($order->shipping_total, $currency);
80 138 $purchaseUnits['amount']['breakdown']['shipping'] = [
81 - 'currency_code' => $transaction->currency,
82 - 'value' => $shippingAmount,
139 + 'currency_code' => $currency,
140 + 'value' => PayPalHelper::formatDecimalAmount($shippingAmount, $currency),
83 141 ];
84 142 $pushedTotal += $shippingAmount;
85 143 }
86 144
@@ -98,16 +156,16 @@
98 156
99 157 if ($taxBehavior === 1) {
100 158 // Pure exclusive: all tax is additive on top of item prices.
101 159 // tax_total includes product + fee tax (both exclusive).
102 - $taxTotal = $this->toDecimal($order->tax_total) + $this->toDecimal($order->shipping_tax);
160 + $taxTotal = $this->toDecimal($order->tax_total, $currency) + $this->toDecimal($order->shipping_tax, $currency);
103 161 } elseif ($taxBehavior === 3) {
104 162 // Mixed: only exclusive product + fee tax is additive; shipping conditional.
105 - $taxTotal = $this->toDecimal($exclusiveTaxTotal);
163 + $taxTotal = $this->toDecimal($exclusiveTaxTotal, $currency);
106 164 if ($storeTaxBehavior === 1) {
107 165 // Store is exclusive: fees and shipping are also exclusive.
108 - $taxTotal += $this->toDecimal($order->shipping_tax);
109 - $taxTotal += $this->toDecimal($feeTax);
166 + $taxTotal += $this->toDecimal($order->shipping_tax, $currency);
167 + $taxTotal += $this->toDecimal($feeTax, $currency);
110 168 }
111 169 } else {
112 170 $taxTotal = 0;
113 171 }
@@ -113,10 +171,10 @@
113 171 }
114 172
115 173 if ($taxTotal > 0) {
116 174 $purchaseUnits['amount']['breakdown']['tax_total'] = [
117 - 'currency_code' => $transaction->currency,
118 - 'value' => number_format($taxTotal, 2, '.', ''),
175 + 'currency_code' => $currency,
176 + 'value' => PayPalHelper::formatDecimalAmount($taxTotal, $currency),
119 177 ];
120 178 $pushedTotal += $taxTotal;
121 179 }
122 180
@@ -122,10 +180,10 @@
122 180
123 181 if ($chargingAmount < $pushedTotal) {
124 182 $discount = $pushedTotal - $chargingAmount;
125 183 $purchaseUnits['amount']['breakdown']['discount'] = [
126 - 'currency_code' => $transaction->currency,
127 - 'value' => number_format($discount, 2, '.', ''),
184 + 'currency_code' => $currency,
185 + 'value' => PayPalHelper::formatDecimalAmount($discount, $currency),
128 186 ];
129 187 } else if ($chargingAmount > $pushedTotal) {
130 188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
131 189 $formattedItems[] = [
@@ -130,10 +188,10 @@
130 188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
131 189 $formattedItems[] = [
132 190 'name' => __('Adjustment Amount', 'fluent-cart'),
133 191 'unit_amount' => [
134 - 'currency_code' => $transaction->currency,
135 - 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''),
192 + 'currency_code' => $currency,
193 + 'value' => PayPalHelper::formatDecimalAmount($extraChargeNeedToBeAdded, $currency),
136 194 ],
137 195 'quantity' => 1,
138 196 ];
139 197
@@ -140,13 +198,93 @@
140 198 $purchaseUnits['items'] = $formattedItems;
141 199
142 200 //now the total amount need to be adjusted with item total value
143 201 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
144 - $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', '');
202 + $purchaseUnits['amount']['breakdown']['item_total']['value'] = PayPalHelper::formatDecimalAmount($adjustedItemTotal, $currency);
145 203 }
146 204
147 - $paypalOrder = API::createOrder($purchaseUnits);
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 + );
148 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 +
149 287 if (is_wp_error($paypalOrder)) {
150 288 return $paypalOrder;
151 289 }
152 290
@@ -175,8 +313,200 @@
175 313 ]
176 314 ];
177 315 }
178 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 +
179 509 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
180 510 {
181 511 $orderType = $paymentInstance->order->type;
182 512 $subscription = $paymentInstance->subscription;
@@ -214,9 +544,9 @@
214 544 'currency' => $paymentInstance->order->currency,
215 545 'interval_count' => 1, // 1
216 546 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
217 547 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
218 - 'bill_times' => (int)$subscription->bill_times, // 0 for unlimited
548 + 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
219 549 ];
220 550
221 551 }
222 552
@@ -225,14 +555,20 @@
225 555 if (is_wp_error($paypalPlan)) {
226 556 return $paypalPlan;
227 557 }
228 558
229 - $subscription->update([
559 + $subscriptionUpdateFields = [
230 560 'status' => $status,
231 561 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
232 562 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
233 - ]);
563 + ];
234 564
565 + $subscription->update($subscriptionUpdateFields);
566 +
567 + if ($orderType == 'renewal' && !empty($data['trial_days'])) {
568 + $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
569 + }
570 +
235 571 return [
236 572 'status' => 'success',
237 573 'nextAction' => 'paypal',
238 574 'actionName' => 'custom',
@@ -248,9 +584,13 @@
248 584 'uuid' => $subscription->uuid,
249 585 ]
250 586 ],
251 587 'response' => [
252 - 'planId' => Arr::get($paypalPlan, 'id')
588 + 'planId' => Arr::get($paypalPlan, 'id'),
589 + 'brandName' => (new PayPalSettingsBase())->getBrandName([
590 + 'order' => $paymentInstance->order,
591 + 'subscription' => $subscription,
592 + ]),
253 593 ]
254 594 ];
255 595 }
256 596
@@ -298,8 +638,24 @@
298 638 }
299 639
300 640 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
301 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 +
302 658 $transaction->fill($transactionUpdateData);
303 659 $transaction->save();
304 660
305 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', [
@@ -323,12 +679,16 @@
323 679 if (!$subscriptionModel) {
324 680 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
325 681 }
326 682
327 - if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
328 - return $subscriptionModel; // already active or invalid
683 + if (!$subscriptionModel) {
684 + return null;
329 685 }
330 686
687 + if ($order->type !== Status::ORDER_TYPE_RENEWAL && $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
688 + return $subscriptionModel;
689 + }
690 +
331 691 // Verify the PayPal subscription's plan matches the expected plan
332 692 if ($subscriptionModel->vendor_plan_id) {
333 693 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
334 694 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
@@ -367,20 +727,29 @@
367 727 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
368 728 'current_payment_method' => 'paypal',
369 729 ]);
370 730
371 - $transactionUpdateData = [];
372 - $lastTransactionAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
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', ''));
373 733
374 - if (($lastTransactionAmount && $transaction->total == $lastTransactionAmount) || $transaction->total == 0) {
375 - $transactionUpdateData = [
376 - 'order_id' => $order->id,
377 - 'status' => Status::TRANSACTION_SUCCEEDED,
378 - 'payment_method' => 'paypal'
379 - ];
380 - }
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;
381 744
382 - if ($transactionUpdateData) {
745 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
746 +
747 + $initialPaymentVerified = $lastPaymentAmount
748 + && $expectedAmount == $lastPaymentAmount
749 + && $currencyMatches;
750 +
751 + if ($initialPaymentVerified || $transaction->total == 0) {
383 752 $transactionUpdateData = array_filter([
384 753 'order_id' => $order->id,
385 754 'status' => Status::TRANSACTION_SUCCEEDED,
386 755 'payment_method' => 'paypal',
@@ -387,8 +756,27 @@
387 756 ]);
388 757
389 758 $transaction->fill($transactionUpdateData);
390 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 + );
391 779 }
392 780
393 781
394 782 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
@@ -399,12 +787,21 @@
399 787 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
400 788 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
401 789 ]);
402 790
403 - SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
404 - 'billing_info' => $billingInfo,
405 - 'subscription_args' => $subscriptionUpdateData
406 - ]);
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 + }
407 804
408 805 } else {
409 806 // This can be a trialing subscription
410 807 if ($subscriptionModel->trial_days > 0) {
@@ -410,22 +807,34 @@
410 807 if ($subscriptionModel->trial_days > 0) {
411 808 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
412 809 }
413 810
414 - $oldStatus = $subscriptionModel->status;
811 + $subscriptionUpdateData['bill_count'] = $subscriptionModel->calculateBillCount();
415 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 +
416 821 $subscriptionModel->fill($subscriptionUpdateData);
417 - $subscriptionModel->save();
418 822
419 - $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
420 - 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
421 - 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
422 - 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
423 - 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
424 - ]));
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 + ]));
425 833
426 - if ($oldStatus != $subscriptionModel->status && (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status)) {
427 - (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
834 + if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
835 + (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
836 + }
428 837 }
429 838 }
430 839
431 840 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
@@ -443,10 +852,350 @@
443 852 return $subscriptionModel;
444 853 }
445 854
446 855
447 - private function toDecimal($cents)
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)
448 862 {
449 - return Helper::toDecimalWithoutComma($cents);
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);
450 1199 }
451 1200
452 1201 }