| @@ -86,8 +86,9 @@ | ||
| 86 | 86 | $postTitle = $product->post_title; |
| 87 | 87 | } |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | + $variation = null; | |
| 90 | 91 | if (!$variationTitle) { |
| 91 | 92 | $variation = ProductVariation::query()->find(Arr::get($checkoutItem, 'object_id', 0)); |
| 92 | 93 | if ($variation) { |
| 93 | 94 | $variationTitle = $variation->variation_title; |
| @@ -93,9 +94,33 @@ | ||
| 93 | 94 | $variationTitle = $variation->variation_title; |
| 94 | 95 | } |
| 95 | 96 | } |
| 96 | 97 | |
| 98 | + if (!$variation) { | |
| 99 | + $variation = ProductVariation::query()->find(Arr::get($checkoutItem, 'object_id', 0)); | |
| 100 | + } | |
| 97 | 101 | |
| 102 | + $fulfillmentType = Arr::get($checkoutItem, 'fulfillment_type', ''); | |
| 103 | + if (!$fulfillmentType && $variation) { | |
| 104 | + $fulfillmentType = $variation->fulfillment_type; | |
| 105 | + } | |
| 106 | + if (!$fulfillmentType) { | |
| 107 | + $fulfillmentType = 'digital'; | |
| 108 | + } | |
| 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 | + | |
| 98 | 123 | $item = [ |
| 99 | 124 | 'payment_type' => $paymentType, |
| 100 | 125 | 'post_id' => Arr::get($checkoutItem, 'post_id'), |
| 101 | 126 | 'object_id' => Arr::get($checkoutItem, 'object_id'), |
| @@ -100,9 +125,9 @@ | ||
| 100 | 125 | 'post_id' => Arr::get($checkoutItem, 'post_id'), |
| 101 | 126 | 'object_id' => Arr::get($checkoutItem, 'object_id'), |
| 102 | 127 | 'post_title' => $postTitle, |
| 103 | 128 | 'title' => $variationTitle, |
| 104 | - 'fulfillment_type' => Arr::get($checkoutItem, 'fulfillment_type', 'digital'), | |
| 129 | + 'fulfillment_type' => $fulfillmentType, | |
| 105 | 130 | 'quantity' => $quantity, |
| 106 | 131 | 'cost' => (int)Arr::get($checkoutItem, 'cost', 0), |
| 107 | 132 | 'unit_price' => $unitPrice, |
| 108 | 133 | 'subtotal' => $subtotal, |
| @@ -108,8 +133,9 @@ | ||
| 108 | 133 | 'subtotal' => $subtotal, |
| 109 | 134 | 'tax_amount' => $tax, |
| 110 | 135 | 'shipping_charge' => $shippingCharge, |
| 111 | 136 | 'discount_total' => $discountTotal, |
| 137 | + 'coupon_discount' => (int)Arr::get($checkoutItem, 'discount_total', 0), | |
| 112 | 138 | 'other_info' => $args, |
| 113 | 139 | 'line_meta' => [] |
| 114 | 140 | ]; |
| 115 | 141 | |
| @@ -118,13 +144,18 @@ | ||
| 118 | 144 | // We have a signup fee for subscription |
| 119 | 145 | $singupFeeAmount = (int)Arr::get($checkoutItem, 'other_info.signup_fee', 0); |
| 120 | 146 | |
| 121 | 147 | $childDiscontTotal = 0; |
| 148 | + $childCouponDiscount = 0; | |
| 122 | 149 | $signupFeeSubtotal = $singupFeeAmount * $quantity; |
| 150 | + $couponDiscount = $item['coupon_discount']; | |
| 151 | + | |
| 123 | 152 | if ($discountTotal) { |
| 124 | 153 | // Distribute discount with signup fee and item |
| 125 | 154 | $childDiscontTotal = (int)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal); |
| 126 | 155 | $discountTotal -= $childDiscontTotal; |
| 156 | + $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal)); | |
| 157 | + $couponDiscount -= $childCouponDiscount; | |
| 127 | 158 | } |
| 128 | 159 | |
| 129 | 160 | $childItem = [ |
| 130 | 161 | 'payment_type' => 'signup_fee', |
| @@ -139,12 +170,14 @@ | ||
| 139 | 170 | 'subtotal' => $signupFeeSubtotal, |
| 140 | 171 | 'tax_amount' => 0, |
| 141 | 172 | 'shipping_charge' => 0, |
| 142 | 173 | 'discount_total' => $childDiscontTotal, |
| 174 | + 'coupon_discount' => $childCouponDiscount, | |
| 143 | 175 | 'line_meta' => [] |
| 144 | 176 | ]; |
| 145 | 177 | |
| 146 | 178 | $item['discount_total'] = $discountTotal; |
| 179 | + $item['coupon_discount'] = $couponDiscount; | |
| 147 | 180 | $item['additional_items'] = [$childItem]; |
| 148 | 181 | |
| 149 | 182 | Arr::set($item, 'other_info.signup_fee', $singupFeeAmount); |
| 150 | 183 | Arr::set($item, 'other_info.signup_discount', $childDiscontTotal); |
| @@ -242,13 +275,35 @@ | ||
| 242 | 275 | 'note' => Arr::get($this->args, 'note', ''), |
| 243 | 276 | 'ip_address' => Arr::get($this->args, 'ip_address', ''), |
| 244 | 277 | 'config' => [ |
| 245 | 278 | 'user_tz' => Arr::get($this->args, 'user_tz', ''), |
| 279 | + 'source' => 'admin', | |
| 246 | 280 | ], |
| 247 | 281 | ]; |
| 248 | 282 | |
| 249 | 283 | $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['shipping_total'] + $orderData['tax_total']; |
| 250 | 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 | + | |
| 251 | 306 | $this->orderData = $orderData; |
| 252 | 307 | } |
| 253 | 308 | |
| 254 | 309 | |
| @@ -288,8 +343,14 @@ | ||
| 288 | 343 | $bundleItems = Arr::get($orderItem, 'bundle_items', []); |
| 289 | 344 | unset($orderItem['bundle_items']); |
| 290 | 345 | } |
| 291 | 346 | |
| 347 | + if (!empty($orderItem['coupon_discount'])) { | |
| 348 | + $lineMeta = Arr::get($orderItem, 'line_meta', []); | |
| 349 | + $lineMeta['coupon_discount'] = (int) $orderItem['coupon_discount']; | |
| 350 | + $orderItem['line_meta'] = $lineMeta; | |
| 351 | + } | |
| 352 | + | |
| 292 | 353 | $createdItem = OrderItem::query()->create($orderItem); |
| 293 | 354 | |
| 294 | 355 | if ($additionalItems) { |
| 295 | 356 | $additionalItemIds = []; |
| @@ -297,8 +358,11 @@ | ||
| 297 | 358 | $additionalItem['order_id'] = $this->orderModel->id; |
| 298 | 359 | $additionalItem['line_total'] = $additionalItem['subtotal'] - $additionalItem['discount_total']; |
| 299 | 360 | $mata = Arr::get($additionalItem, 'line_meta', []); |
| 300 | 361 | $mata['parent_item_id'] = $createdItem->id; |
| 362 | + if (!empty($additionalItem['coupon_discount'])) { | |
| 363 | + $mata['coupon_discount'] = (int) $additionalItem['coupon_discount']; | |
| 364 | + } | |
| 301 | 365 | $additionalItem['line_meta'] = $mata; |
| 302 | 366 | OrderItem::query()->create($additionalItem); |
| 303 | 367 | } |
| 304 | 368 | |
| @@ -319,8 +383,11 @@ | ||
| 319 | 383 | $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0); |
| 320 | 384 | $bundleItem['payment_type'] = 'bundle'; |
| 321 | 385 | $meta = Arr::get($bundleItem, 'line_meta', []); |
| 322 | 386 | $meta['bundle_parent_item_id'] = $createdItem->id; |
| 387 | + if (!empty($bundleItem['coupon_discount'])) { | |
| 388 | + $meta['coupon_discount'] = (int) $bundleItem['coupon_discount']; | |
| 389 | + } | |
| 323 | 390 | $bundleItem['line_meta'] = $meta; |
| 324 | 391 | $bundleItem = OrderItem::query()->create($bundleItem); |
| 325 | 392 | $bundleItemIds[] = $bundleItem->id; |
| 326 | 393 | } |
| @@ -341,8 +408,37 @@ | ||
| 341 | 408 | $subscriptionData = $this->subscriptionData; |
| 342 | 409 | $subscriptionData['customer_id'] = $customerId; |
| 343 | 410 | $subscriptionData['parent_order_id'] = $this->orderModel->id; |
| 344 | 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 | + } | |
| 345 | 441 | } |
| 346 | 442 | |
| 347 | 443 | // Let's create the transaction |
| 348 | 444 | $transactionData = [ |
| @@ -460,9 +556,9 @@ | ||
| 460 | 556 | if ($firstPrice < $recurringPrice) { |
| 461 | 557 | Arr::set($item, 'other_info.trial_days', PaymentHelper::getIntervalDays(Arr::get($item, 'other_info.repeat_interval'))); |
| 462 | 558 | Arr::set($item, 'other_info.signup_fee', $firstPrice); |
| 463 | 559 | Arr::set($item, 'other_info.manage_setup_fee', 'yes'); |
| 464 | - 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 | |
| 465 | 561 | } else if ($firstPrice > $recurringPrice) { |
| 466 | 562 | Arr::set($item, 'other_info.signup_fee', $firstPrice - $recurringPrice); |
| 467 | 563 | Arr::set($item, 'other_info.manage_setup_fee', 'yes'); |
| 468 | 564 | Arr::set($item, 'other_info.trial_days', 0); |
| @@ -488,11 +584,16 @@ | ||
| 488 | 584 | 'bill_count' => 0, |
| 489 | 585 | 'quantity' => 1, |
| 490 | 586 | 'variation_id' => Arr::get($item, 'object_id', 0), |
| 491 | 587 | 'status' => Status::SUBSCRIPTION_PENDING, |
| 588 | + 'collection_method' => Status::SUBSCRIPTION_METHOD_MANUAL, | |
| 492 | 589 | 'config' => [ |
| 493 | 590 | 'currency' => $this->orderData['currency'], |
| 494 | 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', ''), | |
| 495 | 596 | ] |
| 496 | 597 | ]; |
| 497 | 598 | |
| 498 | 599 | $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); |
| @@ -624,9 +725,10 @@ | ||
| 624 | 725 | $result['trial_days'] = $adjustedTrialDays; |
| 625 | 726 | $result['is_trial_days_simulated'] = 'yes'; |
| 626 | 727 | $result['signup_fee'] = $firstCycleCost; |
| 627 | 728 | $result['manage_setup_fee'] = 'yes'; |
| 628 | - $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. | |
| 629 | 731 | } else if ($firstCycleCost > $recurringAmount) { |
| 630 | 732 | $result['trial_days'] = 0; |
| 631 | 733 | $result['signup_fee'] = $firstCycleCost - $recurringAmount; |
| 632 | 734 | $result['manage_setup_fee'] = 'yes'; |