| @@ -275,13 +275,35 @@ | ||
| 275 | 275 | 'note' => Arr::get($this->args, 'note', ''), |
| 276 | 276 | 'ip_address' => Arr::get($this->args, 'ip_address', ''), |
| 277 | 277 | 'config' => [ |
| 278 | 278 | 'user_tz' => Arr::get($this->args, 'user_tz', ''), |
| 279 | + 'source' => 'admin', | |
| 279 | 280 | ], |
| 280 | 281 | ]; |
| 281 | 282 | |
| 282 | 283 | $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['shipping_total'] + $orderData['tax_total']; |
| 283 | 284 | $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0; |
| 285 | + | |
| 286 | + /** | |
| 287 | + * Filter the prepared order data before it is used for order creation. | |
| 288 | + * | |
| 289 | + * This mirrors the frontend checkout hook so integrations can adjust | |
| 290 | + * order payload fields consistently for manual admin orders too. | |
| 291 | + * | |
| 292 | + * @param array $orderData Prepared order data array. | |
| 293 | + * @param array $context { | |
| 294 | + * Additional context for the filter. | |
| 295 | + * | |
| 296 | + * @type array $items Formatted order items with prices and quantities. | |
| 297 | + * @type array $args Admin order arguments: customer data, payment | |
| 298 | + * method, shipping, tax, notes, and IP data. | |
| 299 | + * } | |
| 300 | + */ | |
| 301 | + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [ | |
| 302 | + 'items' => $this->formattedIOrderItems, | |
| 303 | + 'args' => $this->args, | |
| 304 | + ]); | |
| 305 | + | |
| 284 | 306 | $this->orderData = $orderData; |
| 285 | 307 | } |
| 286 | 308 | |
| 287 | 309 | |
| @@ -386,8 +408,37 @@ | ||
| 386 | 408 | $subscriptionData = $this->subscriptionData; |
| 387 | 409 | $subscriptionData['customer_id'] = $customerId; |
| 388 | 410 | $subscriptionData['parent_order_id'] = $this->orderModel->id; |
| 389 | 411 | $this->subscriptionModel = Subscription::query()->create($subscriptionData); |
| 412 | + | |
| 413 | + // bill_count counting can't tell "billed cycle" from "something else | |
| 414 | + // charged alongside it." Must be decided at creation: payment-method switching | |
| 415 | + // also sets is_trial_days_simulated, so the flag alone can't be trusted at runtime. | |
| 416 | + $isSimulated = Arr::get($subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes'; | |
| 417 | + $trialDays = (int)Arr::get($subscriptionData, 'trial_days', 0); | |
| 418 | + $billTimes = (int)$this->subscriptionModel->bill_times; | |
| 419 | + $orderTotal = (int)$this->orderModel->total_amount; | |
| 420 | + | |
| 421 | + // Simulated trial, $0 first cycle: consumes a cycle but produces no | |
| 422 | + // total > 0 transaction — add billed_cycles_offset so it still counts. | |
| 423 | + if ($isSimulated | |
| 424 | + && $billTimes > 0 | |
| 425 | + && (int)$this->subscriptionModel->signup_fee <= 0 | |
| 426 | + && !$orderTotal | |
| 427 | + ) { | |
| 428 | + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1); | |
| 429 | + } | |
| 430 | + | |
| 431 | + // Real trial with a signup fee: the initial charge is the signup fee only, | |
| 432 | + // but it IS a total > 0 transaction linked to the subscription — mark | |
| 433 | + // billed_cycles_deduction so it does NOT count as a cycle. | |
| 434 | + if (!$isSimulated | |
| 435 | + && $trialDays > 0 | |
| 436 | + && $billTimes > 0 | |
| 437 | + && $orderTotal > 0 | |
| 438 | + ) { | |
| 439 | + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1); | |
| 440 | + } | |
| 390 | 441 | } |
| 391 | 442 | |
| 392 | 443 | // Let's create the transaction |
| 393 | 444 | $transactionData = [ |
| @@ -505,9 +556,9 @@ | ||
| 505 | 556 | if ($firstPrice < $recurringPrice) { |
| 506 | 557 | Arr::set($item, 'other_info.trial_days', PaymentHelper::getIntervalDays(Arr::get($item, 'other_info.repeat_interval'))); |
| 507 | 558 | Arr::set($item, 'other_info.signup_fee', $firstPrice); |
| 508 | 559 | Arr::set($item, 'other_info.manage_setup_fee', 'yes'); |
| 509 | - Arr::set($item, 'other_info.times', Arr::get($item, 'other_info.times', 0) > 1 ? Arr::get($item, 'other_info.times', 0) - 1 : 0); | |
| 560 | + // times stays the full installment count — the simulated trial cycle is installment #1 | |
| 510 | 561 | } else if ($firstPrice > $recurringPrice) { |
| 511 | 562 | Arr::set($item, 'other_info.signup_fee', $firstPrice - $recurringPrice); |
| 512 | 563 | Arr::set($item, 'other_info.manage_setup_fee', 'yes'); |
| 513 | 564 | Arr::set($item, 'other_info.trial_days', 0); |
| @@ -533,8 +584,9 @@ | ||
| 533 | 584 | 'bill_count' => 0, |
| 534 | 585 | 'quantity' => 1, |
| 535 | 586 | 'variation_id' => Arr::get($item, 'object_id', 0), |
| 536 | 587 | 'status' => Status::SUBSCRIPTION_PENDING, |
| 588 | + 'collection_method' => Status::SUBSCRIPTION_METHOD_MANUAL, | |
| 537 | 589 | 'config' => [ |
| 538 | 590 | 'currency' => $this->orderData['currency'], |
| 539 | 591 | 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'), |
| 540 | 592 | // Snapshot the variant attribute map + variation type from the order |
| @@ -673,9 +725,10 @@ | ||
| 673 | 725 | $result['trial_days'] = $adjustedTrialDays; |
| 674 | 726 | $result['is_trial_days_simulated'] = 'yes'; |
| 675 | 727 | $result['signup_fee'] = $firstCycleCost; |
| 676 | 728 | $result['manage_setup_fee'] = 'yes'; |
| 677 | - $result['times'] = $times > 0 ? $times - 1 : 0; | |
| 729 | + // bill_times stays the full installment count. The simulated trial cycle IS the | |
| 730 | + // first installment; gateways derive remaining remote cycles from is_trial_days_simulated. | |
| 678 | 731 | } else if ($firstCycleCost > $recurringAmount) { |
| 679 | 732 | $result['trial_days'] = 0; |
| 680 | 733 | $result['signup_fee'] = $firstCycleCost - $recurringAmount; |
| 681 | 734 | $result['manage_setup_fee'] = 'yes'; |