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.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 1.3.20 1.3.19 All 49 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/Processor.php +164 -37 1.6.0 → 1.6.5 View file →
@@ -18,19 +18,76 @@
18 18 use FluentCart\Framework\Support\Arr;
19 19
20 20 class Processor
21 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 +
22 78 public function handleSinglePayment(PaymentInstance $paymentInstance, $args = [])
23 79 {
24 80 $transaction = $paymentInstance->transaction;
25 81 $order = $paymentInstance->order;
26 82
83 + $currency = $transaction->currency;
27 84 $itemsSubTotal = 0;
28 85 $formattedItems = [];
29 86
30 87 foreach ($order->order_items as $item) {
31 88 $quantity = $item->quantity ?? 1;
32 - $perQuantity = $this->toDecimal($item->line_total / $quantity);
89 + $perQuantity = $this->toDecimal($item->line_total / $quantity, $currency);
33 90 $title = $item->post_title . ' ' . $item->title;
34 91
35 92 $formattedItems[] = [
36 93 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
@@ -35,10 +92,10 @@
35 92 $formattedItems[] = [
36 93 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title,
37 94 'description' => strlen($title) > 4000 ? substr($title, 0, 3997) . '...' : $title,
38 95 'unit_amount' => [
39 - 'currency_code' => $transaction->currency,
40 - 'value' => number_format($perQuantity, 2, '.', ''),
96 + 'currency_code' => $currency,
97 + 'value' => PayPalHelper::formatDecimalAmount($perQuantity, $currency),
41 98 ],
42 99 'quantity' => $quantity,
43 100 ];
44 101
@@ -44,9 +101,9 @@
44 101
45 102 $itemsSubTotal += $perQuantity * $quantity;
46 103 }
47 104
48 - $chargingAmount = $this->toDecimal($transaction->total);
105 + $chargingAmount = $this->toDecimal($transaction->total, $currency);
49 106 $pushedTotal = $itemsSubTotal;
50 107
51 108
52 109 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
@@ -52,14 +109,14 @@
52 109 // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit
53 110 $purchaseUnits = [
54 111 'reference_id' => $transaction->uuid, // This is the order UUID
55 112 'amount' => [ // https://developer.paypal.com/docs/api/orders/v2/#definition-amount_breakdown
56 - 'currency_code' => $transaction->currency,
57 - 'value' => number_format($chargingAmount, 2, '.', ''),
113 + 'currency_code' => $currency,
114 + 'value' => PayPalHelper::formatDecimalAmount($chargingAmount, $currency),
58 115 'breakdown' => [
59 116 'item_total' => [
60 - 'currency_code' => $transaction->currency,
61 - 'value' => number_format($itemsSubTotal, 2, '.', ''),
117 + 'currency_code' => $currency,
118 + 'value' => PayPalHelper::formatDecimalAmount($itemsSubTotal, $currency),
62 119 ]
63 120 ]
64 121 ],
65 122 'items' => $formattedItems
@@ -76,12 +133,12 @@
76 133 }
77 134 }
78 135
79 136 if ($order->shipping_total > 0) {
80 - $shippingAmount = $this->toDecimal($order->shipping_total);
137 + $shippingAmount = $this->toDecimal($order->shipping_total, $currency);
81 138 $purchaseUnits['amount']['breakdown']['shipping'] = [
82 - 'currency_code' => $transaction->currency,
83 - 'value' => number_format($shippingAmount, 2, '.', ''),
139 + 'currency_code' => $currency,
140 + 'value' => PayPalHelper::formatDecimalAmount($shippingAmount, $currency),
84 141 ];
85 142 $pushedTotal += $shippingAmount;
86 143 }
87 144
@@ -99,16 +156,16 @@
99 156
100 157 if ($taxBehavior === 1) {
101 158 // Pure exclusive: all tax is additive on top of item prices.
102 159 // tax_total includes product + fee tax (both exclusive).
103 - $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);
104 161 } elseif ($taxBehavior === 3) {
105 162 // Mixed: only exclusive product + fee tax is additive; shipping conditional.
106 - $taxTotal = $this->toDecimal($exclusiveTaxTotal);
163 + $taxTotal = $this->toDecimal($exclusiveTaxTotal, $currency);
107 164 if ($storeTaxBehavior === 1) {
108 165 // Store is exclusive: fees and shipping are also exclusive.
109 - $taxTotal += $this->toDecimal($order->shipping_tax);
110 - $taxTotal += $this->toDecimal($feeTax);
166 + $taxTotal += $this->toDecimal($order->shipping_tax, $currency);
167 + $taxTotal += $this->toDecimal($feeTax, $currency);
111 168 }
112 169 } else {
113 170 $taxTotal = 0;
114 171 }
@@ -114,10 +171,10 @@
114 171 }
115 172
116 173 if ($taxTotal > 0) {
117 174 $purchaseUnits['amount']['breakdown']['tax_total'] = [
118 - 'currency_code' => $transaction->currency,
119 - 'value' => number_format($taxTotal, 2, '.', ''),
175 + 'currency_code' => $currency,
176 + 'value' => PayPalHelper::formatDecimalAmount($taxTotal, $currency),
120 177 ];
121 178 $pushedTotal += $taxTotal;
122 179 }
123 180
@@ -123,10 +180,10 @@
123 180
124 181 if ($chargingAmount < $pushedTotal) {
125 182 $discount = $pushedTotal - $chargingAmount;
126 183 $purchaseUnits['amount']['breakdown']['discount'] = [
127 - 'currency_code' => $transaction->currency,
128 - 'value' => number_format($discount, 2, '.', ''),
184 + 'currency_code' => $currency,
185 + 'value' => PayPalHelper::formatDecimalAmount($discount, $currency),
129 186 ];
130 187 } else if ($chargingAmount > $pushedTotal) {
131 188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
132 189 $formattedItems[] = [
@@ -131,10 +188,10 @@
131 188 $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal;
132 189 $formattedItems[] = [
133 190 'name' => __('Adjustment Amount', 'fluent-cart'),
134 191 'unit_amount' => [
135 - 'currency_code' => $transaction->currency,
136 - 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''),
192 + 'currency_code' => $currency,
193 + 'value' => PayPalHelper::formatDecimalAmount($extraChargeNeedToBeAdded, $currency),
137 194 ],
138 195 'quantity' => 1,
139 196 ];
140 197
@@ -141,9 +198,9 @@
141 198 $purchaseUnits['items'] = $formattedItems;
142 199
143 200 //now the total amount need to be adjusted with item total value
144 201 $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded;
145 - $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', '');
202 + $purchaseUnits['amount']['breakdown']['item_total']['value'] = PayPalHelper::formatDecimalAmount($adjustedItemTotal, $currency);
146 203 }
147 204
148 205 // System (auto-charged, store-billed) subscription checkout: vault the
149 206 // buyer's PayPal account during this purchase (Vault v3 save-on-success)
@@ -148,10 +205,26 @@
148 205 // System (auto-charged, store-billed) subscription checkout: vault the
149 206 // buyer's PayPal account during this purchase (Vault v3 save-on-success)
150 207 // so future renewal invoices can be charged merchant-initiated. The buyer
151 208 // sees and approves the save agreement inside PayPal's own approval UI.
209 + // Vaulting on a plain one-time order cannot be requested from outside core:
210 + // the vault_attributes filter below fires only once this branch is already
211 + // taken, so it can shape a vault but never ask for one. This filter is the
212 + // PayPal counterpart of fluent_cart/payments/stripe_onetime_intent_args, and
213 + // it is what lets the saved-payment-methods module vault on buyer consent.
214 + // Defaults to the existing value, so with no listener behaviour is unchanged.
215 + $vaultOnSuccess = apply_filters(
216 + 'fluent_cart/payments/paypal_vault_one_time',
217 + !empty($args['vault_on_success']),
218 + [
219 + 'order' => $order,
220 + 'transaction' => $transaction,
221 + 'subscription' => $paymentInstance->subscription,
222 + ]
223 + );
224 +
152 225 $extraBody = [];
153 - if (!empty($args['vault_on_success'])) {
226 + if ($vaultOnSuccess) {
154 227 $vaultAttributes = apply_filters('fluent_cart/paypal/vault_attributes', [
155 228 'store_in_vault' => 'ON_SUCCESS',
156 229 'usage_type' => 'MERCHANT',
157 230 'customer_type' => 'CONSUMER',
@@ -173,8 +246,32 @@
173 246 }
174 247
175 248 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
176 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 +
177 274 if (is_wp_error($paypalOrder)) {
178 275 return $paypalOrder;
179 276 }
180 277
@@ -440,15 +537,14 @@
440 537 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
441 538 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
442 539 ];
443 540
541 + $subscription->update($subscriptionUpdateFields);
542 +
444 543 if ($orderType == 'renewal' && !empty($data['trial_days'])) {
445 - $config = $subscription->config ?: [];
446 - $subscriptionUpdateFields['config'] = array_merge($config, ['is_trial_days_simulated' => 'yes']);
544 + $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
447 545 }
448 546
449 - $subscription->update($subscriptionUpdateFields);
450 -
451 547 return [
452 548 'status' => 'success',
453 549 'nextAction' => 'paypal',
454 550 'actionName' => 'custom',
@@ -514,8 +610,24 @@
514 610 }
515 611
516 612 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
517 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 +
518 630 $transaction->fill($transactionUpdateData);
519 631 $transaction->save();
520 632
521 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', [
@@ -601,10 +713,12 @@
601 713 // never deliver a paid product for free.
602 714 $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
603 715 || strtoupper($transaction->currency) === $lastPaymentCurrency;
604 716
717 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
718 +
605 719 $initialPaymentVerified = $lastPaymentAmount
606 - && $transaction->total == $lastPaymentAmount
720 + && $expectedAmount == $lastPaymentAmount
607 721 && $currencyMatches;
608 722
609 723 if ($initialPaymentVerified || $transaction->total == 0) {
610 724 $transactionUpdateData = array_filter([
@@ -622,9 +736,9 @@
622 736 __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
623 737 sprintf(
624 738 /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
625 739 __('Subscription initial payment mismatch. Expected: %1$s %2$s, Received: %3$s %4$s. Order not marked paid; awaiting webhook.', 'fluent-cart'),
626 - Helper::toDecimal($transaction->total),
740 + Helper::toDecimal($expectedAmount),
627 741 $transaction->currency,
628 742 Helper::toDecimal($lastPaymentAmount),
629 743 $lastPaymentCurrency
630 744 ),
@@ -708,11 +822,16 @@
708 822 return $subscriptionModel;
709 823 }
710 824
711 825
712 - 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)
713 832 {
714 - return Helper::toDecimalWithoutComma($cents);
833 + return PayPalHelper::toDecimalAmount($cents, $currency);
715 834 }
716 835
717 836 /**
718 837 * Persist the vaulted PayPal payment token from a captured order onto the
@@ -830,9 +949,9 @@
830 949 'reference_id' => $transaction->uuid,
831 950 'custom_id' => $transaction->uuid,
832 951 'amount' => [
833 952 'currency_code' => strtoupper($transaction->currency),
834 - 'value' => number_format($this->toDecimal((int) $transaction->total), 2, '.', ''),
953 + 'value' => PayPalHelper::formatAmount((int) $transaction->total, $transaction->currency),
835 954 ],
836 955 ];
837 956
838 957 $paypalOrder = API::createOrder($purchaseUnit, [
@@ -839,9 +958,9 @@
839 958 'payment_source' => ['paypal' => ['vault_id' => $token]],
840 959 ], [
841 960 // One vendor charge per (order, attempt) — a scheduler double-fire
842 961 // replays the original response instead of charging twice.
843 - 'PayPal-Request-Id' => 'fct_system_charge_' . $order->id . '_' . $attempt,
962 + 'PayPal-Request-Id' => 'fct_system_charge_' . $order->uuid . '_' . $attempt,
844 963 ]);
845 964
846 965 if (is_wp_error($paypalOrder)) {
847 966 return $paypalOrder;
@@ -942,15 +1061,17 @@
942 1061 return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
943 1062 }
944 1063
945 1064 $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0));
946 - if ($captureAmount !== (int) $transaction->total) {
1065 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
1066 +
1067 + if ($captureAmount !== $expectedAmount) {
947 1068 fluent_cart_warning_log(
948 1069 __('PayPal Amount Mismatch On Sync', 'fluent-cart'),
949 1070 sprintf(
950 1071 /* translators: %1$s: expected amount, %2$s: received amount */
951 1072 __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
952 - Helper::toDecimal($transaction->total),
1073 + Helper::toDecimal($expectedAmount),
953 1074 Helper::toDecimal($captureAmount)
954 1075 ),
955 1076 [
956 1077 'module_name' => 'order',
@@ -992,11 +1113,17 @@
992 1113 * ids for reconciliation, confirm completed captures through the normal
993 1114 * capture path, report settling captures as 'processing', everything else as
994 1115 * a definitive failure with PayPal's reason.
995 1116 *
996 - * @return true|string|\WP_Error
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
997 1124 */
998 - private function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
1125 + public function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
999 1126 {
1000 1127 $orderStatus = strtoupper((string) Arr::get($paypalOrder, 'status', ''));
1001 1128 $capture = Arr::get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
1002 1129 $captureId = Arr::get($capture, 'id', '');