← 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', ''); |