PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
← All changes | app/Modules/PaymentMethods/PayPalGateway/Processor.php +200 -43 1.6.0 → 1.7.1 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',
@@ -171,10 +244,47 @@
171 244 ],
172 245 ];
173 246 }
174 247
248 + $brandName = (new PayPalSettingsBase())->getBrandName([
249 + 'order' => $order,
250 + 'subscription' => $paymentInstance->subscription,
251 + ]);
252 +
253 + if ($brandName !== '') {
254 + if (isset($extraBody['payment_source'])) {
255 + $extraBody['payment_source']['paypal']['experience_context']['brand_name'] = $brandName;
256 + } else {
257 + $extraBody['application_context'] = ['shipping_preference' => 'NO_SHIPPING', 'brand_name' => $brandName];
258 + }
259 + }
260 +
175 261 $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
176 262
263 + // Vaulting is a convenience; the purchase is the point. A merchant account
264 + // not approved for vaulting can reject the order outright because of the
265 + // vault attributes, and failing the sale over a save the buyer merely
266 + // opted into would be the wrong trade. Retry once without them and let
267 + // listeners record that this account cannot vault, so the saving UI can
268 + // stop being offered instead of failing silently on every order.
269 + //
270 + // ONLY for an error that actually implicates the vault attributes. An auth
271 + // failure, rate limit, malformed amount or transport error is not evidence
272 + // that this account cannot vault: retrying would not fix it, and telling a
273 + // listener otherwise would switch saving off for a perfectly capable
274 + // account on the strength of an unrelated outage.
275 + if (is_wp_error($paypalOrder) && $vaultOnSuccess && self::isVaultRejection($paypalOrder)) {
276 + do_action('fluent_cart/payments/paypal_vault_rejected', [
277 + 'order' => $order,
278 + 'transaction' => $transaction,
279 + 'error' => $paypalOrder,
280 + ]);
281 +
282 + unset($extraBody['payment_source']['paypal']['attributes']);
283 +
284 + $paypalOrder = API::createOrder($purchaseUnits, $extraBody);
285 + }
286 +
177 287 if (is_wp_error($paypalOrder)) {
178 288 return $paypalOrder;
179 289 }
180 290
@@ -216,18 +326,29 @@
216 326 {
217 327 $order = $paymentInstance->order;
218 328 $transaction = $paymentInstance->transaction;
219 329
330 + $experienceContext = [
331 + 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
332 + 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
333 + 'shipping_preference' => 'NO_SHIPPING',
334 + ];
335 +
336 + $brandName = (new PayPalSettingsBase())->getBrandName([
337 + 'order' => $order,
338 + 'subscription' => $paymentInstance->subscription,
339 + ]);
340 +
341 + if ($brandName !== '') {
342 + $experienceContext['brand_name'] = $brandName;
343 + }
344 +
220 345 $setupToken = API::makeRequest('vault/setup-tokens', 'v3', 'POST', [
221 346 'payment_source' => [
222 347 'paypal' => [
223 348 'usage_type' => 'MERCHANT',
224 349 'customer_type' => 'CONSUMER',
225 - 'experience_context' => [
226 - 'return_url' => PaymentHelper::getCustomPaymentLink($order->uuid),
227 - 'cancel_url' => \FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway::getCancelUrl(),
228 - 'shipping_preference' => 'NO_SHIPPING',
229 - ],
350 + 'experience_context' => $experienceContext,
230 351 ],
231 352 ],
232 353 ]);
233 354
@@ -440,15 +561,14 @@
440 561 'vendor_plan_id' => Arr::get($paypalPlan, 'id'),
441 562 'vendor_response' => json_encode($paypalPlan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
442 563 ];
443 564
565 + $subscription->update($subscriptionUpdateFields);
566 +
444 567 if ($orderType == 'renewal' && !empty($data['trial_days'])) {
445 - $config = $subscription->config ?: [];
446 - $subscriptionUpdateFields['config'] = array_merge($config, ['is_trial_days_simulated' => 'yes']);
568 + $subscription->mergeConfig(['is_trial_days_simulated' => 'yes']);
447 569 }
448 570
449 - $subscription->update($subscriptionUpdateFields);
450 -
451 571 return [
452 572 'status' => 'success',
453 573 'nextAction' => 'paypal',
454 574 'actionName' => 'custom',
@@ -464,9 +584,13 @@
464 584 'uuid' => $subscription->uuid,
465 585 ]
466 586 ],
467 587 'response' => [
468 - 'planId' => Arr::get($paypalPlan, 'id')
588 + 'planId' => Arr::get($paypalPlan, 'id'),
589 + 'brandName' => (new PayPalSettingsBase())->getBrandName([
590 + 'order' => $paymentInstance->order,
591 + 'subscription' => $subscription,
592 + ]),
469 593 ]
470 594 ];
471 595 }
472 596
@@ -514,8 +638,24 @@
514 638 }
515 639
516 640 $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', []));
517 641
642 + // A zero-decimal total is stored x100 but charged rounded, so PayPal reports back a
643 + // figure up to half a unit away from the stored one. The wire comparison upstream has
644 + // already proved this is the same payment. Keep the stored number: it is what the
645 + // order's line items sum to, so adopting the rounded one would either strand the order
646 + // partially_paid (rounded down) or fake an overpayment (rounded up). Record what
647 + // actually moved in meta instead. activateSubscription() already leaves total alone.
648 + $reportedTotal = (int)Arr::get($transactionUpdateData, 'total', 0);
649 + if ($reportedTotal
650 + && $reportedTotal !== (int)$transaction->total
651 + && PayPalHelper::currencyDecimals($transaction->currency) === 0
652 + && $reportedTotal === PayPalHelper::wireCents($transaction->total, $transaction->currency)
653 + ) {
654 + unset($transactionUpdateData['total']);
655 + $transactionUpdateData['meta']['wire_total'] = $reportedTotal;
656 + }
657 +
518 658 $transaction->fill($transactionUpdateData);
519 659 $transaction->save();
520 660
521 661 fluent_cart_add_log(__('PayPal Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from PayPal. Transaction ID: ', 'fluent-cart') . Arr::get($transactionArgs, 'vendor_charge_id', ''), 'info', [
@@ -601,10 +741,12 @@
601 741 // never deliver a paid product for free.
602 742 $currencyMatches = !$lastPaymentCurrency || !$transaction->currency
603 743 || strtoupper($transaction->currency) === $lastPaymentCurrency;
604 744
745 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
746 +
605 747 $initialPaymentVerified = $lastPaymentAmount
606 - && $transaction->total == $lastPaymentAmount
748 + && $expectedAmount == $lastPaymentAmount
607 749 && $currencyMatches;
608 750
609 751 if ($initialPaymentVerified || $transaction->total == 0) {
610 752 $transactionUpdateData = array_filter([
@@ -622,9 +764,9 @@
622 764 __('PayPal Subscription Payment Mismatch', 'fluent-cart'),
623 765 sprintf(
624 766 /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */
625 767 __('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),
768 + Helper::toDecimal($expectedAmount),
627 769 $transaction->currency,
628 770 Helper::toDecimal($lastPaymentAmount),
629 771 $lastPaymentCurrency
630 772 ),
@@ -665,8 +807,10 @@
665 807 if ($subscriptionModel->trial_days > 0) {
666 808 $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING;
667 809 }
668 810
811 + $subscriptionUpdateData['bill_count'] = $subscriptionModel->calculateBillCount();
812 +
669 813 // Atomic conditional update: only the caller that actually flips status out of a
670 814 // pre-active state wins the transition, so concurrent AJAX-return + webhook calls
671 815 // can't both dispatch SubscriptionActivated.
672 816 $activatedNow = (bool) Subscription::query()
@@ -708,11 +852,16 @@
708 852 return $subscriptionModel;
709 853 }
710 854
711 855
712 - private function toDecimal($cents)
856 + /**
857 + * Cents to a decimal amount rounded to the precision PayPal accepts for
858 + * the currency (HUF/JPY/TWD take no decimals). Rounding before the
859 + * breakdown arithmetic keeps the parts summing to the total.
860 + */
861 + private function toDecimal($cents, $currency)
713 862 {
714 - return Helper::toDecimalWithoutComma($cents);
863 + return PayPalHelper::toDecimalAmount($cents, $currency);
715 864 }
716 865
717 866 /**
718 867 * Persist the vaulted PayPal payment token from a captured order onto the
@@ -830,9 +979,9 @@
830 979 'reference_id' => $transaction->uuid,
831 980 'custom_id' => $transaction->uuid,
832 981 'amount' => [
833 982 'currency_code' => strtoupper($transaction->currency),
834 - 'value' => number_format($this->toDecimal((int) $transaction->total), 2, '.', ''),
983 + 'value' => PayPalHelper::formatAmount((int) $transaction->total, $transaction->currency),
835 984 ],
836 985 ];
837 986
838 987 $paypalOrder = API::createOrder($purchaseUnit, [
@@ -839,9 +988,9 @@
839 988 'payment_source' => ['paypal' => ['vault_id' => $token]],
840 989 ], [
841 990 // One vendor charge per (order, attempt) — a scheduler double-fire
842 991 // replays the original response instead of charging twice.
843 - 'PayPal-Request-Id' => 'fct_system_charge_' . $order->id . '_' . $attempt,
992 + 'PayPal-Request-Id' => 'fct_system_charge_' . $order->uuid . '_' . $attempt,
844 993 ]);
845 994
846 995 if (is_wp_error($paypalOrder)) {
847 996 return $paypalOrder;
@@ -942,15 +1091,17 @@
942 1091 return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart'));
943 1092 }
944 1093
945 1094 $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0));
946 - if ($captureAmount !== (int) $transaction->total) {
1095 + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency);
1096 +
1097 + if ($captureAmount !== $expectedAmount) {
947 1098 fluent_cart_warning_log(
948 1099 __('PayPal Amount Mismatch On Sync', 'fluent-cart'),
949 1100 sprintf(
950 1101 /* translators: %1$s: expected amount, %2$s: received amount */
951 1102 __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
952 - Helper::toDecimal($transaction->total),
1103 + Helper::toDecimal($expectedAmount),
953 1104 Helper::toDecimal($captureAmount)
954 1105 ),
955 1106 [
956 1107 'module_name' => 'order',
@@ -992,11 +1143,17 @@
992 1143 * ids for reconciliation, confirm completed captures through the normal
993 1144 * capture path, report settling captures as 'processing', everything else as
994 1145 * a definitive failure with PayPal's reason.
995 1146 *
996 - * @return true|string|\WP_Error
1147 + * Public so an extension charging a vaulted token outside the renewal engine
1148 + * (saved payment methods) settles through this exact contract rather than
1149 + * reimplementing it. The PENDING branch in particular is money-critical: a
1150 + * settling eCheck is neither paid nor failed, and a duplicate of this logic
1151 + * would eventually drift and mis-report one.
1152 + *
1153 + * @return true|string|\WP_Error true = captured, 'processing' = settling
997 1154 */
998 - private function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
1155 + public function settleVaultChargeResponse(OrderTransaction $transaction, $paypalOrder)
999 1156 {
1000 1157 $orderStatus = strtoupper((string) Arr::get($paypalOrder, 'status', ''));
1001 1158 $capture = Arr::get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
1002 1159 $captureId = Arr::get($capture, 'id', '');