| @@ -486,19 +486,25 @@ | ||
| 486 | 486 | ->where('order_id', $this->orderModel->id) |
| 487 | 487 | ->first(); |
| 488 | 488 | |
| 489 | 489 | if ($existingTransaction) { |
| 490 | + $meta = $existingTransaction->meta ?: []; | |
| 491 | + | |
| 490 | 492 | // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed): |
| 491 | 493 | // re-submitting a pending transaction is a duplicate (keep attempt -> gateway |
| 492 | 494 | // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed, |
| 493 | 495 | // never answered with the failed attempt's cached gateway response). |
| 494 | - $attempt = (int) Arr::get($existingTransaction->meta ?: [], 'payment_attempt', 0); | |
| 496 | + $attempt = (int) Arr::get($meta, 'payment_attempt', 0); | |
| 495 | 497 | if ($existingTransaction->status === Status::PAYMENT_FAILED) { |
| 496 | 498 | $attempt++; |
| 497 | 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. | |
| 498 | 503 | if ($attempt) { |
| 499 | - $transactionData['meta'] = ['payment_attempt' => $attempt]; | |
| 504 | + $meta['payment_attempt'] = $attempt; | |
| 500 | 505 | } |
| 506 | + $transactionData['meta'] = $meta; | |
| 501 | 507 | |
| 502 | 508 | $existingTransaction->fill($transactionData); |
| 503 | 509 | $existingTransaction->save(); |
| 504 | 510 | $this->transactionModel = $existingTransaction; |
| @@ -925,9 +931,9 @@ | ||
| 925 | 931 | 'line_meta' => Arr::get($item, 'line_meta', []), |
| 926 | 932 | 'signup_fee' => $signupFee, |
| 927 | 933 | 'signup_fee_tax' => $signupFeeTax, |
| 928 | 934 | 'first_iteration_tax' => $firstIterationTax, |
| 929 | - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'), | |
| 935 | + 'recurring_discount' => $recurringDiscountAmount, | |
| 930 | 936 | 'total_discount' => $discountTotal |
| 931 | 937 | ]); |
| 932 | 938 | |
| 933 | 939 | // removable upon discussion |
| @@ -951,13 +957,8 @@ | ||
| 951 | 957 | 'variation_type' => Arr::get($item, 'other_info.variation_type', '') |
| 952 | 958 | ] |
| 953 | 959 | ]; |
| 954 | 960 | |
| 955 | - // if recurring coupon is applied, we need to subtract the total discount from the recurring total | |
| 956 | - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') { | |
| 957 | - $subscriptionItem['recurring_total'] -= $discountTotal; | |
| 958 | - } | |
| 959 | - | |
| 960 | 961 | $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); |
| 961 | 962 | $paymentMethod = Arr::get($this->orderData, 'payment_method', ''); |
| 962 | 963 | |
| 963 | 964 | $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod()); |
| @@ -1103,8 +1104,30 @@ | ||
| 1103 | 1104 | + $estimatedTaxTotal |
| 1104 | 1105 | + $estimatedShippingTax; |
| 1105 | 1106 | |
| 1106 | 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 | + | |
| 1107 | 1130 | $this->orderData = $orderData; |
| 1108 | 1131 | } |
| 1109 | 1132 | |
| 1110 | 1133 | private function syncFeeItems() |
| @@ -1229,8 +1252,9 @@ | ||
| 1229 | 1252 | $signupFee = (int)($inputData['signup_fee'] ?? 0); |
| 1230 | 1253 | $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0); |
| 1231 | 1254 | $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0); |
| 1232 | 1255 | $totalDiscount = (int)($inputData['total_discount'] ?? 0); |
| 1256 | + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0); | |
| 1233 | 1257 | |
| 1234 | 1258 | // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts) |
| 1235 | 1259 | $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0); |
| 1236 | 1260 | $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false); |
| @@ -1270,10 +1294,13 @@ | ||
| 1270 | 1294 | } |
| 1271 | 1295 | } else { |
| 1272 | 1296 | $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount; |
| 1273 | 1297 | |
| 1274 | - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') { | |
| 1275 | - $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; | |
| 1276 | 1303 | } |
| 1277 | 1304 | |
| 1278 | 1305 | if ($firstCycleCost < $recurringAmount) { |
| 1279 | 1306 | $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval); |