PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/Processor.php +773 -54 1.5.0 → 1.6.5 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,80 @@
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 + $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
249 +
250 + // Vaulting is a convenience; the purchase is the point. A merchant account
251 + // not approved for vaulting can reject the order outright because of the
252 + // vault attributes, and failing the sale over a save the buyer merely
253 + // opted into would be the wrong trade. Retry once without them and let
254 + // listeners record that this account cannot vault, so the saving UI can
255 + // stop being offered instead of failing silently on every order.
256 + //
257 + // ONLY for an error that actually implicates the vault attributes. An auth
258 + // failure, rate limit, malformed amount or transport error is not evidence
259 + // that this account cannot vault: retrying would not fix it, and telling a
260 + // listener otherwise would switch saving off for a perfectly capable
261 + // account on the strength of an unrelated outage.
262 + if (is_wp_error($paypalOrder) && $vaultOnSuccess && self::isVaultRejection($paypalOrder)) {
263 + do_action('fluent_cart/payments/paypal_vault_rejected', [
264 + 'order' => $order,
265 + 'transaction' => $transaction,
266 + 'error' => $paypalOrder,
267 + ]);
268 +
269 + unset($extraBody['payment_source']['paypal']['attributes']);
270 +
271 + $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
272 + }
273 +
149 274 if (is_wp_error($paypalOrder)) {
150 275 return $paypalOrder;
151 276 }
152 277
@@ -175,8 +300,189 @@
175 300 ]
176 301 ];
177 302 }
178 303
304 + /**
305 + * Zero-payable system subscription checkout (free trial): a $0 PayPal order
306 + * is invalid, so the buyer's PayPal account is vaulted via a Vault v3 setup
307 + * token; confirmVaultSetup() exchanges it, completes the $0 order, and the
308 + * trial-end invoice is charged off-session like any other system renewal.
309 + * The save agreement is carried by PayPal's own approval popup; the checkout
310 + * page shows the informational disclosure next to the buttons.
311 + */
312 + public function handleSetupOnlyPayment(PaymentInstance $paymentInstance)
313 + {
314 + $order = $paymentInstance->order;
315 + $transaction = $paymentInstance->transaction;
316 +
317 + $setupToken = API::makeRequest('vault/setup-tokens', 'v3', 'POST', [
318 + 'payment_source' => [
319 + 'paypal' => [
320 + 'usage_type' => 'MERCHANT',
321 + 'customer_type' => 'CONSUMER',
322 + 'experience_context' => [
323 + 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
324 + 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
325 + 'shipping_preference' => 'NO_SHIPPING',
326 + ],
327 + ],
328 + ],
329 + ]);
330 +
331 + if (is_wp_error($setupToken)) {
332 + return $setupToken;
333 + }
334 +
335 + $setupTokenId = Arr::get($setupToken, 'id');
336 +
337 + if (!$setupTokenId) {
338 + return new \WP_Error('setup_token_failed', __('PayPal did not return a setup token.', 'fluent-cart'));
339 + }
340 +
341 + // confirmVaultSetup() binds the buyer's approval to this transaction by
342 + // this id; the write takes the same lock as confirmation so a
343 + // replacement can never interleave with an in-flight confirm.
344 + if (!self::acquireVaultTransactionLock($transaction->uuid)) {
345 + return new \WP_Error('setup_in_progress', __('Another payment confirmation is in progress. Please try again.', 'fluent-cart'));
346 + }
347 +
348 + try {
349 + $transaction->update([
350 + 'meta' => array_merge($transaction->meta ?? [], ['paypal_setup_token_id' => $setupTokenId])
351 + ]);
352 + } finally {
353 + self::releaseVaultTransactionLock($transaction->uuid);
354 + }
355 +
356 + return [
357 + 'nextAction' => 'paypal',
358 + 'actionName' => 'custom',
359 + 'status' => 'success',
360 + 'data' => [
361 + 'order' => [
362 + 'uuid' => $order->uuid,
363 + ],
364 + 'transaction' => [
365 + 'uuid' => $transaction->uuid,
366 + ]
367 + ],
368 + 'message' => __('Order has been placed successfully', 'fluent-cart'),
369 + 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
370 + 'response' => [
371 + 'setupTokenId' => $setupTokenId,
372 + ]
373 + ];
374 + }
375 +
376 + /**
377 + * Vault-flow lock, keyed on the transaction uuid — shared by the setup-token
378 + * binding write and the confirmation endpoint so token replacement and
379 + * confirmation of one transaction always serialize.
380 + */
381 + public static function acquireVaultTransactionLock($transactionUuid)
382 + {
383 + global $wpdb;
384 +
385 + $result = $wpdb->get_var($wpdb->prepare(
386 + 'SELECT GET_LOCK(%s, %d)',
387 + 'fluent_cart_paypal_vault_' . md5($transactionUuid),
388 + 10
389 + ));
390 +
391 + return (string) $result === '1';
392 + }
393 +
394 + public static function releaseVaultTransactionLock($transactionUuid)
395 + {
396 + global $wpdb;
397 +
398 + $wpdb->get_var($wpdb->prepare(
399 + 'SELECT RELEASE_LOCK(%s)',
400 + 'fluent_cart_paypal_vault_' . md5($transactionUuid)
401 + ));
402 + }
403 +
404 + /**
405 + * Exchange an approved setup token for a durable payment token, persist it
406 + * on the system subscription, and complete the $0 order — the trial then
407 + * activates through the normal status-sync path.
408 + *
409 + * @param OrderTransaction $transaction
410 + * @param string $setupTokenId
411 + * @return true|\WP_Error
412 + */
413 + public function confirmVaultSetup(OrderTransaction $transaction, $setupTokenId)
414 + {
415 + // A prior confirmation may have died between marking the transaction
416 + // succeeded and syncing the order — always re-run the idempotent sync.
417 + if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
418 + (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
419 + return true;
420 + }
421 +
422 + /** @var Subscription|null $subscription */
423 + $subscription = Subscription::query()->find($transaction->subscription_id);
424 +
425 + if (!$subscription || !$subscription->isSystem()) {
426 + return new \WP_Error('invalid_subscription', __('No auto-charged subscription is attached to this transaction.', 'fluent-cart'));
427 + }
428 +
429 + // Keyed on the setup token: a double-fired confirmation replays the
430 + // original payment token instead of vaulting twice.
431 + $paymentToken = API::makeRequest('vault/payment-tokens', 'v3', 'POST', [
432 + 'payment_source' => [
433 + 'token' => [
434 + 'id' => $setupTokenId,
435 + 'type' => 'SETUP_TOKEN',
436 + ],
437 + ],
438 + ], '', [
439 + 'PayPal-Request-Id' => 'fct_paypal_pt_' . md5($setupTokenId),
440 + ]);
441 +
442 + if (is_wp_error($paymentToken)) {
443 + return $paymentToken;
444 + }
445 +
446 + $tokenId = Arr::get($paymentToken, 'id');
447 +
448 + if (!$tokenId) {
449 + return new \WP_Error('vault_failed', __('PayPal did not return a saved payment method.', 'fluent-cart'));
450 + }
451 +
452 + $vaultCustomerId = Arr::get($paymentToken, 'customer.id', '');
453 + if ($vaultCustomerId && !$subscription->vendor_customer_id) {
454 + $subscription->vendor_customer_id = $vaultCustomerId;
455 + $subscription->save();
456 + }
457 +
458 + $paypalSource = Arr::get($paymentToken, 'payment_source.paypal', []);
459 + $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
460 + 'email' => Arr::get($paypalSource, 'email_address', ''),
461 + 'payer_id' => Arr::get($paypalSource, 'account_id', ''),
462 + 'name' => trim(Arr::get($paypalSource, 'name.given_name', '') . ' ' . Arr::get($paypalSource, 'name.surname', '')),
463 + ]);
464 + $billingInfo['vendor_method_id'] = $tokenId;
465 +
466 + $subscription->updateMeta('active_payment_method', $billingInfo);
467 +
468 + $subscription->addLog(
469 + 'PayPal account saved',
470 + __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
471 + 'info'
472 + );
473 +
474 + $transaction->fill([
475 + 'status' => Status::TRANSACTION_SUCCEEDED,
476 + 'payment_method' => 'paypal',
477 + ]);
478 + $transaction->save();
479 +
480 + (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
481 +
482 + return true;
483 + }
484 +
179 485 public function handleSubscriptionPaymentFromPaymentInstance(PaymentInstance $paymentInstance, $args = [])
180 486 {
181 487 $orderType = $paymentInstance->order->type;
182 488 $subscription = $paymentInstance->subscription;
@@ -214,9 +520,9 @@
214 520 'currency' => $paymentInstance->order->currency,
215 521 'interval_count' => 1, // 1
216 522 'recurring_amount' => $subscription->recurring_total, // default recurring total in cents
217 523 'signup_fee' => $initialAmount, // default setup fee in cents ($0.00)
218 - 'bill_times' => (int)$subscription->bill_times, // 0 for unlimited
524 + 'bill_times' => $subscription->getInitialRemoteBillTimes(), // 0 for unlimited; simulated-trial first installment excluded
219 525 ];
220 526
221 527 }
222 528
@@ -225,14 +531,20 @@
225 531 if (is_wp_error($paypalPlan)) {
226 532 return $paypalPlan;
227 533 }
228 534
229 - $subscription->update([
535 + $subscriptionUpdateFields = [
230 536 'status' => $status,
231 537 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
232 538 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
233 - ]);
539 + ];
234 540
541 + $subscription->update($subscriptionUpdateFields);
542 +
543 + if ($orderType == 'renewal' && !empty($data['trial_days'])) {
544 + $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
545 + }
546 +
235 547 return [
236 548 'status' => 'success',
237 549 'nextAction' => 'paypal',
238 550 'actionName' => 'custom',
@@ -298,8 +610,24 @@
298 610 }
299 611
300 612 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
301 613
614 + // A zero-decimal total is stored x100 but charged rounded, so PayPal reports back a
615 + // figure up to half a unit away from the stored one. The wire comparison upstream has
616 + // already proved this is the same payment. Keep the stored number: it is what the
617 + // order's line items sum to, so adopting the rounded one would either strand the order
618 + // partially_paid (rounded down) or fake an overpayment (rounded up). Record what
619 + // actually moved in meta instead. activateSubscription() already leaves total alone.
620 + $reportedTotal = (int)Arr::get($transactionUpdateData, 'total', 0);
621 + if ($reportedTotal
622 + && $reportedTotal !== (int)$transaction->total
623 + && PayPalHelper::currencyDecimals($transaction->currency) === 0
624 + && $reportedTotal === PayPalHelper::wireCents($transaction->total, $transaction->currency)
625 + ) {
626 + unset($transactionUpdateData['total']);
627 + $transactionUpdateData['meta']['wire_total'] = $reportedTotal;
628 + }
629 +
302 630 $transaction->fill($transactionUpdateData);
303 631 $transaction->save();
304 632
305 633 fluent_cart_add_log(__('PayPal Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from PayPal. Transaction ID: ', 'fluent-cart') . Arr::get($transactionArgs, 'vendor_charge_id', ''), 'info', [
@@ -323,12 +651,16 @@
323 651 if (!$subscriptionModel) {
324 652 $subscriptionModel = Subscription::query()->where('id', $transaction->subscription_id)->first();
325 653 }
326 654
327 - if (!$subscriptionModel || $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
328 - return $subscriptionModel; // already active or invalid
655 + if (!$subscriptionModel) {
656 + return null;
329 657 }
330 658
659 + if ($order->type !== Status::ORDER_TYPE_RENEWAL && $subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE) {
660 + return $subscriptionModel;
661 + }
662 +
331 663 // Verify the PayPal subscription's plan matches the expected plan
332 664 if ($subscriptionModel->vendor_plan_id) {
333 665 $paypalPlanId = Arr::get($paypalSubscription, 'plan_id', '');
334 666 if ($paypalPlanId && $paypalPlanId !== $subscriptionModel->vendor_plan_id) {
@@ -367,20 +699,29 @@
367 699 'vendor_customer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id', ''),
368 700 'current_payment_method' => 'paypal',
369 701 ]);
370 702
371 - $transactionUpdateData = [];
372 - $lastTransactionAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
703 + $lastPaymentAmount = Helper::toCent(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.value', 0));
704 + $lastPaymentCurrency = strtoupper(Arr::get($paypalSubscription, 'billing_info.last_payment.amount.currency_code', ''));
373 705
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 - }
706 + // A subscription can legitimately be ACTIVE with no initial payment yet — a free
707 + // trial, or a future start_time whose first charge PayPal has not run. Only mark the
708 + // initial transaction SUCCEEDED (which flips the order to paid and triggers
709 + // fulfilment) when PayPal reports a real initial payment whose amount AND currency
710 + // match what we expected, or when nothing is owed (total == 0). ACTIVE alone is never
711 + // treated as paid: an amount- or currency-mismatched payment leaves the order pending
712 + // for the PAYMENT.SALE.COMPLETED webhook to reconcile, so a forced activation can
713 + // never deliver a paid product for free.
714 + $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
715 + || strtoupper($transaction->currency) === $lastPaymentCurrency;
381 716
382 - if ($transactionUpdateData) {
717 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
718 +
719 + $initialPaymentVerified = $lastPaymentAmount
720 + && $expectedAmount == $lastPaymentAmount
721 + && $currencyMatches;
722 +
723 + if ($initialPaymentVerified || $transaction->total == 0) {
383 724 $transactionUpdateData = array_filter([
384 725 'order_id' => $order->id,
385 726 'status' => Status::TRANSACTION_SUCCEEDED,
386 727 'payment_method' => 'paypal',
@@ -387,8 +728,27 @@
387 728 ]);
388 729
389 730 $transaction->fill($transactionUpdateData);
390 731 $transaction->save();
732 + } elseif ($lastPaymentAmount && $transaction->total > 0) {
733 + // A payment was reported but its amount or currency does not match the expected
734 + // charge — do not mark the order paid; record it for audit (possible tampering).
735 + fluent_cart_warning_log(
736 + __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
737 + sprintf(
738 + /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
739 + __('Subscription initial payment mismatch. Expected: %1$s %2$s, Received: %3$s %4$s. Order not marked paid; awaiting webhook.', 'fluent-cart'),
740 + Helper::toDecimal($expectedAmount),
741 + $transaction->currency,
742 + Helper::toDecimal($lastPaymentAmount),
743 + $lastPaymentCurrency
744 + ),
745 + [
746 + 'module_name' => 'order',
747 + 'module_id' => $order->id,
748 + 'log_type' => 'api'
749 + ]
750 + );
391 751 }
392 752
393 753
394 754 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
@@ -399,12 +759,21 @@
399 759 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
400 760 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
401 761 ]);
402 762
403 - SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
404 - 'billing_info' => $billingInfo,
405 - 'subscription_args' => $subscriptionUpdateData
406 - ]);
763 + if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
764 + SubscriptionService::recordManualRenewal($subscriptionModel, $transaction, [
765 + 'billing_info' => $billingInfo,
766 + 'subscription_args' => $subscriptionUpdateData
767 + ]);
768 + } else {
769 + $subscriptionModel->fill($subscriptionUpdateData)->save();
770 + $subscriptionModel->updateMeta('active_payment_method', $billingInfo);
771 + do_action('fluent_cart/renewal/payment_scheduled', [
772 + 'order' => $order,
773 + 'subscription' => $subscriptionModel,
774 + ]);
775 + }
407 776
408 777 } else {
409 778 // This can be a trialing subscription
410 779 if ($subscriptionModel->trial_days > 0) {
@@ -410,22 +779,32 @@
410 779 if ($subscriptionModel->trial_days > 0) {
411 780 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
412 781 }
413 782
414 - $oldStatus = $subscriptionModel->status;
783 + // Atomic conditional update: only the caller that actually flips status out of a
784 + // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
785 + // can't both dispatch SubscriptionActivated.
786 + $activatedNow = (bool) Subscription::query()
787 + ->where('id', $subscriptionModel->id)
788 + ->whereNotIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
789 + ->update($subscriptionUpdateData);
415 790
416 791 $subscriptionModel->fill($subscriptionUpdateData);
417 - $subscriptionModel->save();
418 792
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 - ]));
793 + // updateMeta() is check-then-create with no unique (subscription_id, meta_key)
794 + // constraint — gate it behind $activatedNow too, else a losing concurrent caller
795 + // still inserts a duplicate active_payment_method meta row.
796 + if ($activatedNow) {
797 + $subscriptionModel->updateMeta('active_payment_method', PaymentHelper::parsePaymentMethodDetails('paypal', [
798 + 'email' => Arr::get($paypalSubscription, 'subscriber.email_address'),
799 + 'payer_id' => Arr::get($paypalSubscription, 'subscriber.payer_id'),
800 + 'name' => Arr::get($paypalSubscription, 'subscriber.name.given_name') . ' ' . Arr::get($paypalSubscription, 'subscriber.name.surname'),
801 + 'address' => Arr::get($paypalSubscription, 'subscriber.shipping_address.address')
802 + ]));
425 803
426 - if ($oldStatus != $subscriptionModel->status && (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status)) {
427 - (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
804 + if (Status::SUBSCRIPTION_ACTIVE === $subscriptionModel->status || Status::SUBSCRIPTION_TRIALING === $subscriptionModel->status) {
805 + (new SubscriptionActivated($subscriptionModel, $order, $order->customer))->dispatch();
806 + }
428 807 }
429 808 }
430 809
431 810 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
@@ -443,10 +822,350 @@
443 822 return $subscriptionModel;
444 823 }
445 824
446 825
447 - private function toDecimal($cents)
826 + /**
827 + * Cents to a decimal amount rounded to the precision PayPal accepts for
828 + * the currency (HUF/JPY/TWD take no decimals). Rounding before the
829 + * breakdown arithmetic keeps the parts summing to the total.
830 + */
831 + private function toDecimal($cents, $currency)
448 832 {
449 - return Helper::toDecimalWithoutComma($cents);
833 + return PayPalHelper::toDecimalAmount($cents, $currency);
834 + }
835 +
836 + /**
837 + * Persist the vaulted PayPal payment token from a captured order onto the
838 + * system subscription — the token future renewal charges read (at fire time)
839 + * from active_payment_method. Idempotent per token; shared by the AJAX
840 + * confirmation and the PAYMENT.CAPTURE.COMPLETED webhook (whichever lands
841 + * first wins).
842 + *
843 + * When the FIRST (initial) capture of a system subscription carries NO vault
844 + * token — vaulting declined or unavailable on the merchant account — the
845 + * subscription is demoted to plain manual invoicing immediately: a `system`
846 + * subscription without a token would fail every scheduled charge forever.
847 + *
848 + * @param OrderTransaction $transaction
849 + * @param array $paypalOrder The captured Orders-v2 order (full representation).
850 + */
851 + public function maybePersistVaultToken(OrderTransaction $transaction, $paypalOrder)
852 + {
853 + if (!$transaction->subscription_id || !is_array($paypalOrder)) {
854 + return;
855 + }
856 +
857 + /** @var Subscription|null $subscription */
858 + $subscription = Subscription::query()->find($transaction->subscription_id);
859 +
860 + if (!$subscription || !$subscription->isSystem()) {
861 + return;
862 + }
863 +
864 + $vault = Arr::get($paypalOrder, 'payment_source.paypal.attributes.vault', []);
865 + $tokenId = Arr::get($vault, 'id', '');
866 +
867 + $existing = $subscription->getMeta('active_payment_method', []) ?: [];
868 +
869 + if ($tokenId) {
870 + if (Arr::get($existing, 'vendor_method_id') === $tokenId) {
871 + return; // already persisted (webhook/AJAX race)
872 + }
873 +
874 + $vaultCustomerId = Arr::get($vault, 'customer.id', '');
875 + if ($vaultCustomerId && !$subscription->vendor_customer_id) {
876 + $subscription->vendor_customer_id = $vaultCustomerId;
877 + $subscription->save();
878 + }
879 +
880 + $payerEmail = Arr::get($paypalOrder, 'payment_source.paypal.email_address', '');
881 + if (!$payerEmail) {
882 + $payerEmail = Arr::get($paypalOrder, 'payer.email_address', '');
883 + }
884 +
885 + $payerName = trim(Arr::get($paypalOrder, 'payer.name.given_name', '') . ' ' . Arr::get($paypalOrder, 'payer.name.surname', ''));
886 +
887 + $billingInfo = PaymentHelper::parsePaymentMethodDetails('paypal', [
888 + 'email' => $payerEmail,
889 + 'payer_id' => Arr::get($paypalOrder, 'payer.payer_id', ''),
890 + 'name' => $payerName,
891 + ]);
892 + $billingInfo['vendor_method_id'] = $tokenId;
893 +
894 + $subscription->updateMeta('active_payment_method', $billingInfo);
895 +
896 + $subscription->addLog(
897 + 'PayPal account saved',
898 + __('PayPal payment method vaulted for automatic renewal charges.', 'fluent-cart'),
899 + 'info'
900 + );
901 +
902 + return;
903 + }
904 +
905 + // No token on the INITIAL capture and none stored yet — never leave a
906 + // system subscription that can never be charged.
907 + if ($transaction->order
908 + && $transaction->order->type === Status::ORDER_TYPE_SUBSCRIPTION
909 + && !Arr::get($existing, 'vendor_method_id')
910 + ) {
911 + SystemChargeService::demoteToManual(
912 + $subscription,
913 + __('PayPal did not return a saved payment method for automatic charging.', 'fluent-cart')
914 + );
915 + }
916 + }
917 +
918 + /**
919 + * Merchant-initiated off-session charge of a renewal invoice against the
920 + * vaulted PayPal token (Orders v2 create with payment_source.paypal.vault_id).
921 + * Contract per dev-docs/system-subscriptions/gateway-implementation-guide.md:
922 + * true = confirmed through the normal capture path; 'processing' = accepted
923 + * but settling (eCheck); WP_Error = definitive failure.
924 + */
925 + public function chargeVaultedRenewal(PaymentInstance $paymentInstance, $args = [])
926 + {
927 + $order = $paymentInstance->order;
928 + $transaction = $paymentInstance->transaction;
929 + $subscription = $paymentInstance->subscription;
930 +
931 + if (!$order || !$transaction || !$subscription) {
932 + return new \WP_Error('invalid_instance', __('Renewal invoice is missing its order, transaction, or subscription.', 'fluent-cart'));
933 + }
934 +
935 + // Token read AT FIRE TIME — never snapshotted. Both meta shapes accepted.
936 + $paymentMethodMeta = $subscription->getMeta('active_payment_method', []) ?: [];
937 + $token = Arr::get($paymentMethodMeta, 'vendor_method_id');
938 + if (!$token) {
939 + $token = Arr::get($paymentMethodMeta, 'details.payment_method_id');
940 + }
941 +
942 + if (!$token) {
943 + return new \WP_Error('missing_token', __('No saved PayPal payment method is available for this subscription.', 'fluent-cart'));
944 + }
945 +
946 + $attempt = max(1, (int) Arr::get($args, 'attempt', 1));
947 +
948 + $purchaseUnit = [
949 + 'reference_id' => $transaction->uuid,
950 + 'custom_id' => $transaction->uuid,
951 + 'amount' => [
952 + 'currency_code' => strtoupper($transaction->currency),
953 + 'value' => PayPalHelper::formatAmount((int) $transaction->total, $transaction->currency),
954 + ],
955 + ];
956 +
957 + $paypalOrder = API::createOrder($purchaseUnit, [
958 + 'payment_source' => ['paypal' => ['vault_id' => $token]],
959 + ], [
960 + // One vendor charge per (order, attempt) — a scheduler double-fire
961 + // replays the original response instead of charging twice.
962 + 'PayPal-Request-Id' => 'fct_system_charge_' . $order->uuid . '_' . $attempt,
963 + ]);
964 +
965 + if (is_wp_error($paypalOrder)) {
966 + return $paypalOrder;
967 + }
968 +
969 + return $this->settleVaultChargeResponse($transaction, $paypalOrder);
970 + }
971 +
972 + /**
973 + * Re-check a processing vault charge (lost webhook / slow eCheck). A transient
974 + * API error reports 'processing' — never fail a possibly-settled payment.
975 + */
976 + public function reconcileVaultedRenewal(PaymentInstance $paymentInstance)
977 + {
978 + $transaction = $paymentInstance->transaction;
979 +
980 + if (!$transaction) {
981 + return new \WP_Error('missing_intent', __('No transaction is recorded for this renewal order.', 'fluent-cart'));
982 + }
983 +
984 + // Preferred: the capture id recorded when the charge was accepted.
985 + if ($transaction->vendor_charge_id) {
986 + $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET');
987 +
988 + if (is_wp_error($capture)) {
989 + return 'processing';
990 + }
991 +
992 + $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
993 +
994 + if ($captureStatus === 'COMPLETED') {
995 + $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
996 + 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
997 + 'status' => Status::TRANSACTION_SUCCEEDED,
998 + 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
999 + 'payment_method_type' => 'PayPal',
1000 + ]);
1001 + return true;
1002 + }
1003 +
1004 + if ($captureStatus === 'PENDING') {
1005 + return 'processing';
1006 + }
1007 +
1008 + return new \WP_Error('charge_failed', sprintf(
1009 + /* translators: %1$s: PayPal capture status */
1010 + __('The pending PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
1011 + $captureStatus !== '' ? $captureStatus : 'unknown'
1012 + ));
1013 + }
1014 +
1015 + // Fallback: the vault order id stored at charge time.
1016 + $paypalOrderId = Arr::get($transaction->meta ?? [], 'paypal_vault_order_id', '');
1017 +
1018 + if (!$paypalOrderId) {
1019 + return new \WP_Error('missing_intent', __('No PayPal charge is recorded for this renewal order.', 'fluent-cart'));
1020 + }
1021 +
1022 + $paypalOrder = API::verifyPayment($paypalOrderId);
1023 +
1024 + if (is_wp_error($paypalOrder)) {
1025 + return 'processing';
1026 + }
1027 +
1028 + return $this->settleVaultChargeResponse(OrderTransaction::query()->find($transaction->id), $paypalOrder);
1029 + }
1030 +
1031 + public function syncRemoteTransaction(OrderTransaction $transaction)
1032 + {
1033 + $mode = $transaction->payment_mode ?: '';
1034 +
1035 + $capture = API::makeRequest('payments/captures/' . $transaction->vendor_charge_id, 'v2', 'GET', [], $mode);
1036 +
1037 + if (is_wp_error($capture)) {
1038 + return $capture;
1039 + }
1040 +
1041 + $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1042 +
1043 + if ($captureStatus === 'COMPLETED') {
1044 + $captureCurrency = strtoupper((string) Arr::get($capture, 'amount.currency_code', ''));
1045 + if ($captureCurrency && $transaction->currency && strtoupper($transaction->currency) !== $captureCurrency) {
1046 + fluent_cart_warning_log(
1047 + __('PayPal Currency Mismatch On Sync', 'fluent-cart'),
1048 + sprintf(
1049 + /* translators: %1$s: expected currency, %2$s: received currency */
1050 + __('Capture currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
1051 + $transaction->currency,
1052 + $captureCurrency
1053 + ),
1054 + [
1055 + 'module_name' => 'order',
1056 + 'module_id' => $transaction->order_id,
1057 + 'log_type' => 'api'
1058 + ]
1059 + );
1060 +
1061 + return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
1062 + }
1063 +
1064 + $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0));
1065 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
1066 +
1067 + if ($captureAmount !== $expectedAmount) {
1068 + fluent_cart_warning_log(
1069 + __('PayPal Amount Mismatch On Sync', 'fluent-cart'),
1070 + sprintf(
1071 + /* translators: %1$s: expected amount, %2$s: received amount */
1072 + __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
1073 + Helper::toDecimal($expectedAmount),
1074 + Helper::toDecimal($captureAmount)
1075 + ),
1076 + [
1077 + 'module_name' => 'order',
1078 + 'module_id' => $transaction->order_id,
1079 + 'log_type' => 'api'
1080 + ]
1081 + );
1082 +
1083 + return new \WP_Error('amount_mismatch', __('The PayPal payment amount does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
1084 + }
1085 +
1086 + $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1087 + 'vendor_charge_id' => Arr::get($capture, 'id', $transaction->vendor_charge_id),
1088 + 'status' => Status::TRANSACTION_SUCCEEDED,
1089 + 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1090 + 'payment_method_type' => 'PayPal',
1091 + ]);
1092 +
1093 + return OrderTransaction::query()->find($transaction->id);
1094 + }
1095 +
1096 + if ($captureStatus === 'PENDING') {
1097 + return new \WP_Error('still_pending', sprintf(
1098 + /* translators: %1$s: PayPal pending hold reason */
1099 + __('The payment is still pending at PayPal (reason: %1$s). Please try again later.', 'fluent-cart'),
1100 + Arr::get($capture, 'status_details.reason', '') ?: 'unknown'
1101 + ));
1102 + }
1103 +
1104 + return new \WP_Error('charge_not_completed', sprintf(
1105 + /* translators: %1$s: PayPal capture status */
1106 + __('The PayPal payment could not be completed (status: %1$s).', 'fluent-cart'),
1107 + $captureStatus !== '' ? $captureStatus : 'unknown'
1108 + ));
1109 + }
1110 +
1111 + /**
1112 + * Shared outcome derivation for a vault-charged Orders-v2 order: record the
1113 + * ids for reconciliation, confirm completed captures through the normal
1114 + * capture path, report settling captures as 'processing', everything else as
1115 + * a definitive failure with PayPal's reason.
1116 + *
1117 + * Public so an extension charging a vaulted token outside the renewal engine
1118 + * (saved payment methods) settles through this exact contract rather than
1119 + * reimplementing it. The PENDING branch in particular is money-critical: a
1120 + * settling eCheck is neither paid nor failed, and a duplicate of this logic
1121 + * would eventually drift and mis-report one.
1122 + *
1123 + * @return true|string|\WP_Error true = captured, 'processing' = settling
1124 + */
1125 + public function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
1126 + {
1127 + $orderStatus = strtoupper((string) Arr::get($paypalOrder, 'status', ''));
1128 + $capture = Arr::get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
1129 + $captureId = Arr::get($capture, 'id', '');
1130 + $captureStatus = strtoupper((string) Arr::get($capture, 'status', ''));
1131 +
1132 + // Persist ids FIRST — the reconciliation loop and webhook dedup key on them.
1133 + $transactionMeta = array_merge($transaction->meta ?? [], [
1134 + 'paypal_vault_order_id' => Arr::get($paypalOrder, 'id', ''),
1135 + ]);
1136 + $transactionUpdate = ['meta' => $transactionMeta];
1137 + if ($captureId && !$transaction->vendor_charge_id) {
1138 + $transactionUpdate['vendor_charge_id'] = $captureId;
1139 + }
1140 + $transaction->update($transactionUpdate);
1141 +
1142 + if ($captureId && $captureStatus === 'COMPLETED') {
1143 + $this->confirmPaymentSuccessByCharge(OrderTransaction::query()->find($transaction->id), [
1144 + 'vendor_charge_id' => $captureId,
1145 + 'status' => Status::TRANSACTION_SUCCEEDED,
1146 + 'total' => Helper::toCent(Arr::get($capture, 'amount.value', 0)),
1147 + 'payment_method_type' => 'PayPal',
1148 + 'payment_source' => Arr::get($paypalOrder, 'payment_source', []),
1149 + 'meta' => ['payer' => Arr::get($paypalOrder, 'payer', [])],
1150 + ]);
1151 + return true;
1152 + }
1153 +
1154 + if ($captureStatus === 'PENDING' || $orderStatus === 'PENDING') {
1155 + return 'processing';
1156 + }
1157 +
1158 + $reason = Arr::get($capture, 'status_details.reason', '');
1159 +
1160 + if ($reason) {
1161 + /* translators: %1$s: PayPal decline reason code */
1162 + $message = sprintf(__('Automatic PayPal charge failed: %1$s', 'fluent-cart'), $reason);
1163 + } else {
1164 + /* translators: %1$s: PayPal order status */
1165 + $message = sprintf(__('Automatic PayPal charge could not be completed (status: %1$s).', 'fluent-cart'), $orderStatus !== '' ? $orderStatus : 'unknown');
1166 + }
1167 +
1168 + return new \WP_Error('charge_failed', $message);
450 1169 }
451 1170
452 1171 }