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 +827 -53 1.3.26 → 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
@@ -85,13 +143,38 @@
85 143 }
86 144
87 145
88 146
89 - $taxTotal = $this->toDecimal($order->tax_total) + $this->toDecimal($order->shipping_tax);
90 - if ($taxTotal > 0 && $order->tax_behavior == 1) {
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) {
91 174 $purchaseUnits['amount']['breakdown']['tax_total'] = [
92 - 'currency_code' => $transaction->currency,
93 - 'value' => number_format($taxTotal, 2, '.', ''),
175 + 'currency_code' => $currency,
176 + 'value' => PayPalHelper::formatDecimalAmount($taxTotal, $currency),
94 177 ];
95 178 $pushedTotal += $taxTotal;
96 179 }
97 180
@@ -97,10 +180,10 @@
97 180
98 181 if ($chargingAmount < $pushedTotal) {
99 182 $discount = $pushedTotal - $chargingAmount;
100 183 $purchaseUnits['amount']['breakdown']['discount'] = [
101 - 'currency_code' => $transaction->currency,
102 - 'value' => number_format($discount, 2, '.', ''),
184 + 'currency_code' => $currency,
185 + 'value' => PayPalHelper::formatDecimalAmount($discount, $currency),
103 186 ];
104 187 } else if ($chargingAmount > $pushedTotal) {
105 188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
106 189 $formattedItems[] = [
@@ -105,10 +188,10 @@
105 188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
106 189 $formattedItems[] = [
107 190 'name' => __('Adjustment Amount', 'fluent-cart'),
108 191 'unit_amount' => [
109 - 'currency_code' => $transaction->currency,
110 - 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''),
192 + 'currency_code' => $currency,
193 + 'value' => PayPalHelper::formatDecimalAmount($extraChargeNeedToBeAdded, $currency),
111 194 ],
112 195 'quantity' => 1,
113 196 ];
114 197
@@ -115,13 +198,93 @@
115 198 $purchaseUnits['items'] = $formattedItems;
116 199
117 200 //now the total amount need to be adjusted with item total value
118 201 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
119 - $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', '');
202 + $purchaseUnits['amount']['breakdown']['item_total']['value'] = PayPalHelper::formatDecimalAmount($adjustedItemTotal, $currency);
120 203 }
121 204
122 - $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 + );
123 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 +
124 287 if (is_wp_error($paypalOrder)) {
125 288 return $paypalOrder;
126 289 }
127 290
@@ -150,8 +313,200 @@
150 313 ]
151 314 ];
152 315 }
153 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 +
154 509 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
155 510 {
156 511 $orderType = $paymentInstance->order->type;
157 512 $subscription = $paymentInstance->subscription;
@@ -189,9 +544,9 @@
189 544 'currency' => $paymentInstance->order->currency,
190 545 'interval_count' => 1, // 1
191 546 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
192 547 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
193 - 'bill_times' => (int)$subscription->bill_times, // 0 for unlimited
548 + 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
194 549 ];
195 550
196 551 }
197 552
@@ -200,14 +555,20 @@
200 555 if (is_wp_error($paypalPlan)) {
201 556 return $paypalPlan;
202 557 }
203 558
204 - $subscription->update([
559 + $subscriptionUpdateFields = [
205 560 'status' => $status,
206 561 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
207 562 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
208 - ]);
563 + ];
209 564
565 + $subscription->update($subscriptionUpdateFields);
566 +
567 + if ($orderType == 'renewal' && !empty($data['trial_days'])) {
568 + $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
569 + }
570 +
210 571 return [
211 572 'status' => 'success',
212 573 'nextAction' => 'paypal',
213 574 'actionName' => 'custom',
@@ -223,9 +584,13 @@
223 584 'uuid' => $subscription->uuid,
224 585 ]
225 586 ],
226 587 'response' => [
227 - 'planId' => Arr::get($paypalPlan, 'id')
588 + 'planId' => Arr::get($paypalPlan, 'id'),
589 + 'brandName' => (new PayPalSettingsBase())->getBrandName([
590 + 'order' => $paymentInstance->order,
591 + 'subscription' => $subscription,
592 + ]),
228 593 ]
229 594 ];
230 595 }
231 596
@@ -273,8 +638,24 @@
273 638 }
274 639
275 640 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
276 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 +
277 658 $transaction->fill($transactionUpdateData);
278 659 $transaction->save();
279 660
280 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', [
@@ -298,12 +679,16 @@
298 679 if (!$subscriptionModel) {
299 680 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
300 681 }
301 682
302 - if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
303 - return $subscriptionModel; // already active or invalid
683 + if (!$subscriptionModel) {
684 + return null;
304 685 }
305 686
687 + if ($order->type !== Status::ORDER_TYPE_RENEWAL && $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
688 + return $subscriptionModel;
689 + }
690 +
306 691 // Verify the PayPal subscription's plan matches the expected plan
307 692 if ($subscriptionModel->vendor_plan_id) {
308 693 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
309 694 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
@@ -342,20 +727,29 @@
342 727 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
343 728 'current_payment_method' => 'paypal',
344 729 ]);
345 730
346 - $transactionUpdateData = [];
347 - $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', ''));
348 733
349 - if (($lastTransactionAmount && $transaction->total == $lastTransactionAmount) || $transaction->total == 0) {
350 - $transactionUpdateData = [
351 - 'order_id' => $order->id,
352 - 'status' => Status::TRANSACTION_SUCCEEDED,
353 - 'payment_method' => 'paypal'
354 - ];
355 - }
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;
356 744
357 - 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) {
358 752 $transactionUpdateData = array_filter([
359 753 'order_id' => $order->id,
360 754 'status' => Status::TRANSACTION_SUCCEEDED,
361 755 'payment_method' => 'paypal',
@@ -362,8 +756,27 @@
362 756 ]);
363 757
364 758 $transaction->fill($transactionUpdateData);
365 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 + );
366 779 }
367 780
368 781
369 782 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
@@ -374,12 +787,21 @@
374 787 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
375 788 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
376 789 ]);
377 790
378 - SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
379 - 'billing_info' => $billingInfo,
380 - 'subscription_args' => $subscriptionUpdateData
381 - ]);
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 + }
382 804
383 805 } else {
384 806 // This can be a trialing subscription
385 807 if ($subscriptionModel->trial_days > 0) {
@@ -385,22 +807,34 @@
385 807 if ($subscriptionModel->trial_days > 0) {
386 808 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
387 809 }
388 810
389 - $oldStatus = $subscriptionModel->status;
811 + $subscriptionUpdateData['bill_count'] = $subscriptionModel->calculateBillCount();
390 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 +
391 821 $subscriptionModel->fill($subscriptionUpdateData);
392 - $subscriptionModel->save();
393 822
394 - $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
395 - 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
396 - 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
397 - 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
398 - 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
399 - ]));
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 + ]));
400 833
401 - if ($oldStatus != $subscriptionModel->status && (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status)) {
402 - (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 + }
403 837 }
404 838 }
405 839
406 840 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
@@ -418,10 +852,350 @@
418 852 return $subscriptionModel;
419 853 }
420 854
421 855
422 - 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)
423 862 {
424 - 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);
425 1199 }
426 1200
427 1201 }