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

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

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