← All changes
|
app/Modules/PaymentMethods/PayPalGateway/Processor.php
+92
-36
1.6.1
→
1.7.1
View file →
| @@ -79,14 +79,15 @@ | ||
| 79 | 79 | { |
| 80 | 80 | $transaction = $paymentInstance->transaction; |
| 81 | 81 | $order = $paymentInstance->order; |
| 82 | 82 | |
| 83 | + $currency = $transaction->currency; | |
| 83 | 84 | $itemsSubTotal = 0; |
| 84 | 85 | $formattedItems = []; |
| 85 | 86 | |
| 86 | 87 | foreach ($order->order_items as $item) { |
| 87 | 88 | $quantity = $item->quantity ?? 1; |
| 88 | - $perQuantity = $this->toDecimal($item->line_total / $quantity); | |
| 89 | + $perQuantity = $this->toDecimal($item->line_total / $quantity, $currency); | |
| 89 | 90 | $title = $item->post_title . ' ' . $item->title; |
| 90 | 91 | |
| 91 | 92 | $formattedItems[] = [ |
| 92 | 93 | 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title, |
| @@ -91,10 +92,10 @@ | ||
| 91 | 92 | $formattedItems[] = [ |
| 92 | 93 | 'name' => strlen($title) > 127 ? substr($title, 0, 120) . '...' : $title, |
| 93 | 94 | 'description' => strlen($title) > 4000 ? substr($title, 0, 3997) . '...' : $title, |
| 94 | 95 | 'unit_amount' => [ |
| 95 | - 'currency_code' => $transaction->currency, | |
| 96 | - 'value' => number_format($perQuantity, 2, '.', ''), | |
| 96 | + 'currency_code' => $currency, | |
| 97 | + 'value' => PayPalHelper::formatDecimalAmount($perQuantity, $currency), | |
| 97 | 98 | ], |
| 98 | 99 | 'quantity' => $quantity, |
| 99 | 100 | ]; |
| 100 | 101 | |
| @@ -100,9 +101,9 @@ | ||
| 100 | 101 | |
| 101 | 102 | $itemsSubTotal += $perQuantity * $quantity; |
| 102 | 103 | } |
| 103 | 104 | |
| 104 | - $chargingAmount = $this->toDecimal($transaction->total); | |
| 105 | + $chargingAmount = $this->toDecimal($transaction->total, $currency); | |
| 105 | 106 | $pushedTotal = $itemsSubTotal; |
| 106 | 107 | |
| 107 | 108 | |
| 108 | 109 | // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit |
| @@ -108,14 +109,14 @@ | ||
| 108 | 109 | // Learn more at: https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit |
| 109 | 110 | $purchaseUnits = [ |
| 110 | 111 | 'reference_id' => $transaction->uuid, // This is the order UUID |
| 111 | 112 | 'amount' => [ // https://developer.paypal.com/docs/api/orders/v2/#definition-amount_breakdown |
| 112 | - 'currency_code' => $transaction->currency, | |
| 113 | - 'value' => number_format($chargingAmount, 2, '.', ''), | |
| 113 | + 'currency_code' => $currency, | |
| 114 | + 'value' => PayPalHelper::formatDecimalAmount($chargingAmount, $currency), | |
| 114 | 115 | 'breakdown' => [ |
| 115 | 116 | 'item_total' => [ |
| 116 | - 'currency_code' => $transaction->currency, | |
| 117 | - 'value' => number_format($itemsSubTotal, 2, '.', ''), | |
| 117 | + 'currency_code' => $currency, | |
| 118 | + 'value' => PayPalHelper::formatDecimalAmount($itemsSubTotal, $currency), | |
| 118 | 119 | ] |
| 119 | 120 | ] |
| 120 | 121 | ], |
| 121 | 122 | 'items' => $formattedItems |
| @@ -132,12 +133,12 @@ | ||
| 132 | 133 | } |
| 133 | 134 | } |
| 134 | 135 | |
| 135 | 136 | if ($order->shipping_total > 0) { |
| 136 | - $shippingAmount = $this->toDecimal($order->shipping_total); | |
| 137 | + $shippingAmount = $this->toDecimal($order->shipping_total, $currency); | |
| 137 | 138 | $purchaseUnits['amount']['breakdown']['shipping'] = [ |
| 138 | - 'currency_code' => $transaction->currency, | |
| 139 | - 'value' => number_format($shippingAmount, 2, '.', ''), | |
| 139 | + 'currency_code' => $currency, | |
| 140 | + 'value' => PayPalHelper::formatDecimalAmount($shippingAmount, $currency), | |
| 140 | 141 | ]; |
| 141 | 142 | $pushedTotal += $shippingAmount; |
| 142 | 143 | } |
| 143 | 144 | |
| @@ -155,16 +156,16 @@ | ||
| 155 | 156 | |
| 156 | 157 | if ($taxBehavior === 1) { |
| 157 | 158 | // Pure exclusive: all tax is additive on top of item prices. |
| 158 | 159 | // tax_total includes product + fee tax (both exclusive). |
| 159 | - $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); | |
| 160 | 161 | } elseif ($taxBehavior === 3) { |
| 161 | 162 | // Mixed: only exclusive product + fee tax is additive; shipping conditional. |
| 162 | - $taxTotal = $this->toDecimal($exclusiveTaxTotal); | |
| 163 | + $taxTotal = $this->toDecimal($exclusiveTaxTotal, $currency); | |
| 163 | 164 | if ($storeTaxBehavior === 1) { |
| 164 | 165 | // Store is exclusive: fees and shipping are also exclusive. |
| 165 | - $taxTotal += $this->toDecimal($order->shipping_tax); | |
| 166 | - $taxTotal += $this->toDecimal($feeTax); | |
| 166 | + $taxTotal += $this->toDecimal($order->shipping_tax, $currency); | |
| 167 | + $taxTotal += $this->toDecimal($feeTax, $currency); | |
| 167 | 168 | } |
| 168 | 169 | } else { |
| 169 | 170 | $taxTotal = 0; |
| 170 | 171 | } |
| @@ -170,10 +171,10 @@ | ||
| 170 | 171 | } |
| 171 | 172 | |
| 172 | 173 | if ($taxTotal > 0) { |
| 173 | 174 | $purchaseUnits['amount']['breakdown']['tax_total'] = [ |
| 174 | - 'currency_code' => $transaction->currency, | |
| 175 | - 'value' => number_format($taxTotal, 2, '.', ''), | |
| 175 | + 'currency_code' => $currency, | |
| 176 | + 'value' => PayPalHelper::formatDecimalAmount($taxTotal, $currency), | |
| 176 | 177 | ]; |
| 177 | 178 | $pushedTotal += $taxTotal; |
| 178 | 179 | } |
| 179 | 180 | |
| @@ -179,10 +180,10 @@ | ||
| 179 | 180 | |
| 180 | 181 | if ($chargingAmount < $pushedTotal) { |
| 181 | 182 | $discount = $pushedTotal - $chargingAmount; |
| 182 | 183 | $purchaseUnits['amount']['breakdown']['discount'] = [ |
| 183 | - 'currency_code' => $transaction->currency, | |
| 184 | - 'value' => number_format($discount, 2, '.', ''), | |
| 184 | + 'currency_code' => $currency, | |
| 185 | + 'value' => PayPalHelper::formatDecimalAmount($discount, $currency), | |
| 185 | 186 | ]; |
| 186 | 187 | } else if ($chargingAmount > $pushedTotal) { |
| 187 | 188 | $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal; |
| 188 | 189 | $formattedItems[] = [ |
| @@ -187,10 +188,10 @@ | ||
| 187 | 188 | $extraChargeNeedToBeAdded = $chargingAmount - $pushedTotal; |
| 188 | 189 | $formattedItems[] = [ |
| 189 | 190 | 'name' => __('Adjustment Amount', 'fluent-cart'), |
| 190 | 191 | 'unit_amount' => [ |
| 191 | - 'currency_code' => $transaction->currency, | |
| 192 | - 'value' => number_format($extraChargeNeedToBeAdded, 2, '.', ''), | |
| 192 | + 'currency_code' => $currency, | |
| 193 | + 'value' => PayPalHelper::formatDecimalAmount($extraChargeNeedToBeAdded, $currency), | |
| 193 | 194 | ], |
| 194 | 195 | 'quantity' => 1, |
| 195 | 196 | ]; |
| 196 | 197 | |
| @@ -197,9 +198,9 @@ | ||
| 197 | 198 | $purchaseUnits['items'] = $formattedItems; |
| 198 | 199 | |
| 199 | 200 | //now the total amount need to be adjusted with item total value |
| 200 | 201 | $adjustedItemTotal = $itemsSubTotal + $extraChargeNeedToBeAdded; |
| 201 | - $purchaseUnits['amount']['breakdown']['item_total']['value'] = number_format($adjustedItemTotal, 2, '.', ''); | |
| 202 | + $purchaseUnits['amount']['breakdown']['item_total']['value'] = PayPalHelper::formatDecimalAmount($adjustedItemTotal, $currency); | |
| 202 | 203 | } |
| 203 | 204 | |
| 204 | 205 | // System (auto-charged, store-billed) subscription checkout: vault the |
| 205 | 206 | // buyer's PayPal account during this purchase (Vault v3 save-on-success) |
| @@ -243,8 +244,21 @@ | ||
| 243 | 244 | ], |
| 244 | 245 | ]; |
| 245 | 246 | } |
| 246 | 247 | |
| 248 | + $brandName = (new PayPalSettingsBase())->getBrandName([ | |
| 249 | + 'order' => $order, | |
| 250 | + 'subscription' => $paymentInstance->subscription, | |
| 251 | + ]); | |
| 252 | + | |
| 253 | + if ($brandName !== '') { | |
| 254 | + if (isset($extraBody['payment_source'])) { | |
| 255 | + $extraBody['payment_source']['paypal']['experience_context']['brand_name'] = $brandName; | |
| 256 | + } else { | |
| 257 | + $extraBody['application_context'] = ['shipping_preference' => 'NO_SHIPPING', 'brand_name' => $brandName]; | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 247 | 261 | $paypalOrder = API::createOrder($purchaseUnits, $extraBody); |
| 248 | 262 | |
| 249 | 263 | // Vaulting is a convenience; the purchase is the point. A merchant account |
| 250 | 264 | // not approved for vaulting can reject the order outright because of the |
| @@ -312,18 +326,29 @@ | ||
| 312 | 326 | { |
| 313 | 327 | $order = $paymentInstance->order; |
| 314 | 328 | $transaction = $paymentInstance->transaction; |
| 315 | 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 | + | |
| 316 | 345 | $setupToken = API::makeRequest('vault/setup-tokens', 'v3', 'POST', [ |
| 317 | 346 | 'payment_source' => [ |
| 318 | 347 | 'paypal' => [ |
| 319 | 348 | 'usage_type' => 'MERCHANT', |
| 320 | 349 | '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 | - ], | |
| 350 | + 'experience_context' => $experienceContext, | |
| 326 | 351 | ], |
| 327 | 352 | ], |
| 328 | 353 | ]); |
| 329 | 354 | |
| @@ -559,9 +584,13 @@ | ||
| 559 | 584 | 'uuid' => $subscription->uuid, |
| 560 | 585 | ] |
| 561 | 586 | ], |
| 562 | 587 | 'response' => [ |
| 563 | - 'planId' => Arr::get($paypalPlan, 'id') | |
| 588 | + 'planId' => Arr::get($paypalPlan, 'id'), | |
| 589 | + 'brandName' => (new PayPalSettingsBase())->getBrandName([ | |
| 590 | + 'order' => $paymentInstance->order, | |
| 591 | + 'subscription' => $subscription, | |
| 592 | + ]), | |
| 564 | 593 | ] |
| 565 | 594 | ]; |
| 566 | 595 | } |
| 567 | 596 | |
| @@ -609,8 +638,24 @@ | ||
| 609 | 638 | } |
| 610 | 639 | |
| 611 | 640 | $transactionUpdateData['meta'] = array_merge($transaction->meta ?? [], Arr::get($transactionArgs, 'meta', [])); |
| 612 | 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 | + | |
| 613 | 658 | $transaction->fill($transactionUpdateData); |
| 614 | 659 | $transaction->save(); |
| 615 | 660 | |
| 616 | 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', [ |
| @@ -696,10 +741,12 @@ | ||
| 696 | 741 | // never deliver a paid product for free. |
| 697 | 742 | $currencyMatches = !$lastPaymentCurrency || !$transaction->currency |
| 698 | 743 | || strtoupper($transaction->currency) === $lastPaymentCurrency; |
| 699 | 744 | |
| 745 | + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency); | |
| 746 | + | |
| 700 | 747 | $initialPaymentVerified = $lastPaymentAmount |
| 701 | - && $transaction->total == $lastPaymentAmount | |
| 748 | + && $expectedAmount == $lastPaymentAmount | |
| 702 | 749 | && $currencyMatches; |
| 703 | 750 | |
| 704 | 751 | if ($initialPaymentVerified || $transaction->total == 0) { |
| 705 | 752 | $transactionUpdateData = array_filter([ |
| @@ -717,9 +764,9 @@ | ||
| 717 | 764 | __('PayPal Subscription Payment Mismatch', 'fluent-cart'), |
| 718 | 765 | sprintf( |
| 719 | 766 | /* translators: %1$s: expected amount, %2$s: expected currency, %3$s: received amount, %4$s: received currency */ |
| 720 | 767 | __('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), | |
| 768 | + Helper::toDecimal($expectedAmount), | |
| 722 | 769 | $transaction->currency, |
| 723 | 770 | Helper::toDecimal($lastPaymentAmount), |
| 724 | 771 | $lastPaymentCurrency |
| 725 | 772 | ), |
| @@ -760,8 +807,10 @@ | ||
| 760 | 807 | if ($subscriptionModel->trial_days > 0) { |
| 761 | 808 | $subscriptionUpdateData['status'] = Status::SUBSCRIPTION_TRIALING; |
| 762 | 809 | } |
| 763 | 810 | |
| 811 | + $subscriptionUpdateData['bill_count'] = $subscriptionModel->calculateBillCount(); | |
| 812 | + | |
| 764 | 813 | // Atomic conditional update: only the caller that actually flips status out of a |
| 765 | 814 | // pre-active state wins the transition, so concurrent AJAX-return + webhook calls |
| 766 | 815 | // can't both dispatch SubscriptionActivated. |
| 767 | 816 | $activatedNow = (bool) Subscription::query() |
| @@ -803,11 +852,16 @@ | ||
| 803 | 852 | return $subscriptionModel; |
| 804 | 853 | } |
| 805 | 854 | |
| 806 | 855 | |
| 807 | - 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) | |
| 808 | 862 | { |
| 809 | - return Helper::toDecimalWithoutComma($cents); | |
| 863 | + return PayPalHelper::toDecimalAmount($cents, $currency); | |
| 810 | 864 | } |
| 811 | 865 | |
| 812 | 866 | /** |
| 813 | 867 | * Persist the vaulted PayPal payment token from a captured order onto the |
| @@ -925,9 +979,9 @@ | ||
| 925 | 979 | 'reference_id' => $transaction->uuid, |
| 926 | 980 | 'custom_id' => $transaction->uuid, |
| 927 | 981 | 'amount' => [ |
| 928 | 982 | 'currency_code' => strtoupper($transaction->currency), |
| 929 | - 'value' => number_format($this->toDecimal((int) $transaction->total), 2, '.', ''), | |
| 983 | + 'value' => PayPalHelper::formatAmount((int) $transaction->total, $transaction->currency), | |
| 930 | 984 | ], |
| 931 | 985 | ]; |
| 932 | 986 | |
| 933 | 987 | $paypalOrder = API::createOrder($purchaseUnit, [ |
| @@ -934,9 +988,9 @@ | ||
| 934 | 988 | 'payment_source' => ['paypal' => ['vault_id' => $token]], |
| 935 | 989 | ], [ |
| 936 | 990 | // One vendor charge per (order, attempt) — a scheduler double-fire |
| 937 | 991 | // replays the original response instead of charging twice. |
| 938 | - 'PayPal-Request-Id' => 'fct_system_charge_' . $order->id . '_' . $attempt, | |
| 992 | + 'PayPal-Request-Id' => 'fct_system_charge_' . $order->uuid . '_' . $attempt, | |
| 939 | 993 | ]); |
| 940 | 994 | |
| 941 | 995 | if (is_wp_error($paypalOrder)) { |
| 942 | 996 | return $paypalOrder; |
| @@ -1037,15 +1091,17 @@ | ||
| 1037 | 1091 | return new \WP_Error('currency_mismatch', __('The PayPal payment currency does not match this transaction. Please verify the payment at PayPal.', 'fluent-cart')); |
| 1038 | 1092 | } |
| 1039 | 1093 | |
| 1040 | 1094 | $captureAmount = Helper::toCent(Arr::get($capture, 'amount.value', 0)); |
| 1041 | - if ($captureAmount !== (int) $transaction->total) { | |
| 1095 | + $expectedAmount = PayPalHelper::wireCents($transaction->total, $transaction->currency); | |
| 1096 | + | |
| 1097 | + if ($captureAmount !== $expectedAmount) { | |
| 1042 | 1098 | fluent_cart_warning_log( |
| 1043 | 1099 | __('PayPal Amount Mismatch On Sync', 'fluent-cart'), |
| 1044 | 1100 | sprintf( |
| 1045 | 1101 | /* translators: %1$s: expected amount, %2$s: received amount */ |
| 1046 | 1102 | __('Capture amount mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'), |
| 1047 | - Helper::toDecimal($transaction->total), | |
| 1103 | + Helper::toDecimal($expectedAmount), | |
| 1048 | 1104 | Helper::toDecimal($captureAmount) |
| 1049 | 1105 | ), |
| 1050 | 1106 | [ |
| 1051 | 1107 | 'module_name' => 'order', |