| @@ -106,8 +106,21 @@ | ||
| 106 | 106 | if (!$fulfillmentType) { |
| 107 | 107 | $fulfillmentType = 'digital'; |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | + // Snapshot the variant's attribute set so admin-created orders carry | |
| 111 | + // the same pa_* attribute map as storefront orders. | |
| 112 | + if (!isset($args['item_attributes'])) { | |
| 113 | + $args['item_attributes'] = AttributeHelper::getProductItemAttributes( | |
| 114 | + Arr::get($checkoutItem, 'object_id', 0), | |
| 115 | + Arr::get($checkoutItem, 'post_id', 0) | |
| 116 | + ); | |
| 117 | + } | |
| 118 | + | |
| 119 | + if (!isset($args['variation_type'])) { | |
| 120 | + $args['variation_type'] = ($variation && $variation->product_detail) ? $variation->product_detail->variation_type : ''; | |
| 121 | + } | |
| 122 | + | |
| 110 | 123 | $item = [ |
| 111 | 124 | 'payment_type' => $paymentType, |
| 112 | 125 | 'post_id' => Arr::get($checkoutItem, 'post_id'), |
| 113 | 126 | 'object_id' => Arr::get($checkoutItem, 'object_id'), |
| @@ -262,13 +275,35 @@ | ||
| 262 | 275 | 'note' => Arr::get($this->args, 'note', ''), |
| 263 | 276 | 'ip_address' => Arr::get($this->args, 'ip_address', ''), |
| 264 | 277 | 'config' => [ |
| 265 | 278 | 'user_tz' => Arr::get($this->args, 'user_tz', ''), |
| 279 | + 'source' => 'admin', | |
| 266 | 280 | ], |
| 267 | 281 | ]; |
| 268 | 282 | |
| 269 | 283 | $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['shipping_total'] + $orderData['tax_total']; |
| 270 | 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 | + | |
| 271 | 306 | $this->orderData = $orderData; |
| 272 | 307 | } |
| 273 | 308 | |
| 274 | 309 | |
| @@ -373,8 +408,37 @@ | ||
| 373 | 408 | $subscriptionData = $this->subscriptionData; |
| 374 | 409 | $subscriptionData['customer_id'] = $customerId; |
| 375 | 410 | $subscriptionData['parent_order_id'] = $this->orderModel->id; |
| 376 | 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 | + } | |
| 377 | 441 | } |
| 378 | 442 | |
| 379 | 443 | // Let's create the transaction |
| 380 | 444 | $transactionData = [ |
| @@ -492,9 +556,9 @@ | ||
| 492 | 556 | if ($firstPrice < $recurringPrice) { |
| 493 | 557 | Arr::set($item, 'other_info.trial_days', PaymentHelper::getIntervalDays(Arr::get($item, 'other_info.repeat_interval'))); |
| 494 | 558 | Arr::set($item, 'other_info.signup_fee', $firstPrice); |
| 495 | 559 | Arr::set($item, 'other_info.manage_setup_fee', 'yes'); |
| 496 | - 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 | |
| 497 | 561 | } else if ($firstPrice > $recurringPrice) { |
| 498 | 562 | Arr::set($item, 'other_info.signup_fee', $firstPrice - $recurringPrice); |
| 499 | 563 | Arr::set($item, 'other_info.manage_setup_fee', 'yes'); |
| 500 | 564 | Arr::set($item, 'other_info.trial_days', 0); |
| @@ -520,11 +584,16 @@ | ||
| 520 | 584 | 'bill_count' => 0, |
| 521 | 585 | 'quantity' => 1, |
| 522 | 586 | 'variation_id' => Arr::get($item, 'object_id', 0), |
| 523 | 587 | 'status' => Status::SUBSCRIPTION_PENDING, |
| 588 | + 'collection_method' => Status::SUBSCRIPTION_METHOD_MANUAL, | |
| 524 | 589 | 'config' => [ |
| 525 | 590 | 'currency' => $this->orderData['currency'], |
| 526 | 591 | 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'), |
| 592 | + // Snapshot the variant attribute map + variation type from the order | |
| 593 | + // item so the subscription carries the same pa_* set behind its item_name. | |
| 594 | + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []), | |
| 595 | + 'variation_type' => Arr::get($item, 'other_info.variation_type', ''), | |
| 527 | 596 | ] |
| 528 | 597 | ]; |
| 529 | 598 | |
| 530 | 599 | $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); |
| @@ -656,9 +725,10 @@ | ||
| 656 | 725 | $result['trial_days'] = $adjustedTrialDays; |
| 657 | 726 | $result['is_trial_days_simulated'] = 'yes'; |
| 658 | 727 | $result['signup_fee'] = $firstCycleCost; |
| 659 | 728 | $result['manage_setup_fee'] = 'yes'; |
| 660 | - $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. | |
| 661 | 731 | } else if ($firstCycleCost > $recurringAmount) { |
| 662 | 732 | $result['trial_days'] = 0; |
| 663 | 733 | $result['signup_fee'] = $firstCycleCost - $recurringAmount; |
| 664 | 734 | $result['manage_setup_fee'] = 'yes'; |