| @@ -246,8 +246,18 @@ | ||
| 246 | 246 | $cart->user_id = $customer->user_id; |
| 247 | 247 | } |
| 248 | 248 | |
| 249 | 249 | $cart->save(); |
| 250 | + | |
| 251 | + // Carry the traffic source onto the order while the cart still exists. | |
| 252 | + // Carts are pruned on a schedule, so this is the last reliable point at | |
| 253 | + // which the click that produced the sale can still be recovered. | |
| 254 | + UtmHelper::addUtmToOrder( | |
| 255 | + $this->orderModel->id, | |
| 256 | + UtmHelper::resolveUtmData(UtmHelper::getUtmDataOfRequest(), $cart->utm_data), | |
| 257 | + $cart->cart_hash | |
| 258 | + ); | |
| 259 | + | |
| 250 | 260 | $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []); |
| 251 | 261 | if ($actions) { |
| 252 | 262 | foreach ($actions as $actionName) { |
| 253 | 263 | $actionName = (string)$actionName; |
| @@ -476,19 +486,25 @@ | ||
| 476 | 486 | ->where('order_id', $this->orderModel->id) |
| 477 | 487 | ->first(); |
| 478 | 488 | |
| 479 | 489 | if ($existingTransaction) { |
| 490 | + $meta = $existingTransaction->meta ?: []; | |
| 491 | + | |
| 480 | 492 | // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed): |
| 481 | 493 | // re-submitting a pending transaction is a duplicate (keep attempt -> gateway |
| 482 | 494 | // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed, |
| 483 | 495 | // never answered with the failed attempt's cached gateway response). |
| 484 | - $attempt = (int) Arr::get($existingTransaction->meta ?: [], 'payment_attempt', 0); | |
| 496 | + $attempt = (int) Arr::get($meta, 'payment_attempt', 0); | |
| 485 | 497 | if ($existingTransaction->status === Status::PAYMENT_FAILED) { |
| 486 | 498 | $attempt++; |
| 487 | 499 | } |
| 500 | + | |
| 501 | + // The gateway object prepared last time (a Paddle transaction, a PayPal | |
| 502 | + // order) is kept so the gateway can reuse it instead of creating another. | |
| 488 | 503 | if ($attempt) { |
| 489 | - $transactionData['meta'] = ['payment_attempt' => $attempt]; | |
| 504 | + $meta['payment_attempt'] = $attempt; | |
| 490 | 505 | } |
| 506 | + $transactionData['meta'] = $meta; | |
| 491 | 507 | |
| 492 | 508 | $existingTransaction->fill($transactionData); |
| 493 | 509 | $existingTransaction->save(); |
| 494 | 510 | $this->transactionModel = $existingTransaction; |
| @@ -915,9 +931,9 @@ | ||
| 915 | 931 | 'line_meta' => Arr::get($item, 'line_meta', []), |
| 916 | 932 | 'signup_fee' => $signupFee, |
| 917 | 933 | 'signup_fee_tax' => $signupFeeTax, |
| 918 | 934 | 'first_iteration_tax' => $firstIterationTax, |
| 919 | - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'), | |
| 935 | + 'recurring_discount' => $recurringDiscountAmount, | |
| 920 | 936 | 'total_discount' => $discountTotal |
| 921 | 937 | ]); |
| 922 | 938 | |
| 923 | 939 | // removable upon discussion |
| @@ -941,13 +957,8 @@ | ||
| 941 | 957 | 'variation_type' => Arr::get($item, 'other_info.variation_type', '') |
| 942 | 958 | ] |
| 943 | 959 | ]; |
| 944 | 960 | |
| 945 | - // if recurring coupon is applied, we need to subtract the total discount from the recurring total | |
| 946 | - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') { | |
| 947 | - $subscriptionItem['recurring_total'] -= $discountTotal; | |
| 948 | - } | |
| 949 | - | |
| 950 | 961 | $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); |
| 951 | 962 | $paymentMethod = Arr::get($this->orderData, 'payment_method', ''); |
| 952 | 963 | |
| 953 | 964 | $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod()); |
| @@ -1093,8 +1104,30 @@ | ||
| 1093 | 1104 | + $estimatedTaxTotal |
| 1094 | 1105 | + $estimatedShippingTax; |
| 1095 | 1106 | |
| 1096 | 1107 | $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0; |
| 1108 | + | |
| 1109 | + /** | |
| 1110 | + * Filter the prepared order data before it is used for order creation. | |
| 1111 | + * | |
| 1112 | + * This runs after FluentCart calculates totals, so plugins can adjust | |
| 1113 | + * currency, rate, totals, config, mode, or any other order field before | |
| 1114 | + * the order model, transaction, and subscription are derived from it. | |
| 1115 | + * | |
| 1116 | + * @param array $orderData Prepared order data array. | |
| 1117 | + * @param array $context { | |
| 1118 | + * Additional context for the filter. | |
| 1119 | + * | |
| 1120 | + * @type array $items Formatted order items with prices and quantities. | |
| 1121 | + * @type array $args Checkout arguments: customer data, payment method, | |
| 1122 | + * shipping, tax, coupons, fees, and IP data. | |
| 1123 | + * } | |
| 1124 | + */ | |
| 1125 | + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [ | |
| 1126 | + 'items' => $this->formattedIOrderItems, | |
| 1127 | + 'args' => $this->args, | |
| 1128 | + ]); | |
| 1129 | + | |
| 1097 | 1130 | $this->orderData = $orderData; |
| 1098 | 1131 | } |
| 1099 | 1132 | |
| 1100 | 1133 | private function syncFeeItems() |
| @@ -1219,8 +1252,9 @@ | ||
| 1219 | 1252 | $signupFee = (int)($inputData['signup_fee'] ?? 0); |
| 1220 | 1253 | $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0); |
| 1221 | 1254 | $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0); |
| 1222 | 1255 | $totalDiscount = (int)($inputData['total_discount'] ?? 0); |
| 1256 | + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0); | |
| 1223 | 1257 | |
| 1224 | 1258 | // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts) |
| 1225 | 1259 | $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0); |
| 1226 | 1260 | $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false); |
| @@ -1260,10 +1294,13 @@ | ||
| 1260 | 1294 | } |
| 1261 | 1295 | } else { |
| 1262 | 1296 | $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount; |
| 1263 | 1297 | |
| 1264 | - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') { | |
| 1265 | - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount | |
| 1298 | + // A recurring coupon discounts every cycle, so the per-cycle price itself | |
| 1299 | + // is lower — the first cycle is not cheaper than the ones after it and | |
| 1300 | + // must not be expressed as a trial. | |
| 1301 | + if ($recurringDiscount > 0) { | |
| 1302 | + $recurringAmount -= $recurringDiscount; | |
| 1266 | 1303 | } |
| 1267 | 1304 | |
| 1268 | 1305 | if ($firstCycleCost < $recurringAmount) { |
| 1269 | 1306 | $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval); |