storeSettings = new StoreSettings(); $this->checkoutItems = $checkoutItems; $this->checkoutItems = Helper::loadBundleChild($checkoutItems, ['*']); $this->args = $args; $this->prepareData(); } private function prepareData() { $this->prepareOrderItems(); $this->prepareOrderData(); $this->prepareSubscriptionData(); } private function prepareOrderItems() { $formattedItems = []; foreach ($this->checkoutItems as $checkoutItem) { $unitPrice = (int)Arr::get($checkoutItem, 'unit_price', 0); $quantity = (int)Arr::get($checkoutItem, 'quantity', 1); //TODO: right now discount total from admin only comes from coupon applied discount, is same as coupon_discount $this->couponDiscountTotal += (int)Arr::get($checkoutItem, 'discount_total', 0); $this->manualDiscountTotal += (int)Arr::get($checkoutItem, 'manual_discount', 0); $discountTotal = (int)Arr::get($checkoutItem, 'manual_discount', 0) + (int)Arr::get($checkoutItem, 'discount_total', 0); $shippingCharge = (int)Arr::get($checkoutItem, 'shipping_charge', 0); $tax = 0; $subtotal = $unitPrice * $quantity; $args = Arr::get($checkoutItem, 'other_info', []); $paymentType = Arr::get($args, 'payment_type', 'default'); $postTitle = Arr::get($checkoutItem, 'product_title', ''); $variationTitle = Arr::get($checkoutItem, 'variation_title', ''); if (!$postTitle) { $product = Product::query()->find(Arr::get($checkoutItem, 'post_id', 0)); if ($product) { $postTitle = $product->post_title; } } $variation = null; if (!$variationTitle) { $variation = ProductVariation::query()->find(Arr::get($checkoutItem, 'object_id', 0)); if ($variation) { $variationTitle = $variation->variation_title; } } if (!$variation) { $variation = ProductVariation::query()->find(Arr::get($checkoutItem, 'object_id', 0)); } $fulfillmentType = Arr::get($checkoutItem, 'fulfillment_type', ''); if (!$fulfillmentType && $variation) { $fulfillmentType = $variation->fulfillment_type; } if (!$fulfillmentType) { $fulfillmentType = 'digital'; } // Snapshot the variant's attribute set so admin-created orders carry // the same pa_* attribute map as storefront orders. if (!isset($args['item_attributes'])) { $args['item_attributes'] = AttributeHelper::getProductItemAttributes( Arr::get($checkoutItem, 'object_id', 0), Arr::get($checkoutItem, 'post_id', 0) ); } if (!isset($args['variation_type'])) { $args['variation_type'] = ($variation && $variation->product_detail) ? $variation->product_detail->variation_type : ''; } $item = [ 'payment_type' => $paymentType, 'post_id' => Arr::get($checkoutItem, 'post_id'), 'object_id' => Arr::get($checkoutItem, 'object_id'), 'post_title' => $postTitle, 'title' => $variationTitle, 'fulfillment_type' => $fulfillmentType, 'quantity' => $quantity, 'cost' => (int)Arr::get($checkoutItem, 'cost', 0), 'unit_price' => $unitPrice, 'subtotal' => $subtotal, 'tax_amount' => $tax, 'shipping_charge' => $shippingCharge, 'discount_total' => $discountTotal, 'coupon_discount' => (int)Arr::get($checkoutItem, 'discount_total', 0), 'other_info' => $args, 'line_meta' => [] ]; $childItem = null; if ($paymentType === 'subscription' && Arr::get($checkoutItem, 'other_info.signup_fee', 0)) { // We have a signup fee for subscription $singupFeeAmount = (int)Arr::get($checkoutItem, 'other_info.signup_fee', 0); $childDiscontTotal = 0; $childCouponDiscount = 0; $signupFeeSubtotal = $singupFeeAmount * $quantity; $couponDiscount = $item['coupon_discount']; if ($discountTotal) { // Distribute discount with signup fee and item $childDiscontTotal = (int)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal); $discountTotal -= $childDiscontTotal; $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal)); $couponDiscount -= $childCouponDiscount; } $childItem = [ 'payment_type' => 'signup_fee', 'post_id' => $item['post_id'], 'object_id' => $item['object_id'], 'post_title' => $item['post_title'], 'title' => Arr::get($checkoutItem, 'other_info.signup_fee_name', __('Signup Fee', 'fluent-cart')), 'fulfillment_type' => $item['fulfillment_type'], 'quantity' => $quantity, 'cost' => 0, 'unit_price' => $singupFeeAmount, 'subtotal' => $signupFeeSubtotal, 'tax_amount' => 0, 'shipping_charge' => 0, 'discount_total' => $childDiscontTotal, 'coupon_discount' => $childCouponDiscount, 'line_meta' => [] ]; $item['discount_total'] = $discountTotal; $item['coupon_discount'] = $couponDiscount; $item['additional_items'] = [$childItem]; Arr::set($item, 'other_info.signup_fee', $singupFeeAmount); Arr::set($item, 'other_info.signup_discount', $childDiscontTotal); } if (Arr::get($checkoutItem, 'other_info.is_bundle_product', 'no') == 'yes') { $bundleItems = Arr::get($checkoutItem, 'child_variants', []); foreach ($bundleItems as $bundleItem) { $bundleChildItem = [ 'payment_type' => 'bundle', 'post_id' => Arr::get($bundleItem, 'post_id', 0), 'object_id' => Arr::get($bundleItem, 'id', 0), 'post_title' => Arr::get($bundleItem, 'post_title', ''), 'title' => Arr::get($bundleItem, 'variation_title', ''), 'fulfillment_type' => Arr::get($bundleItem, 'fulfillment_type', 'digital'), 'quantity' => $quantity, 'cost' => 0, 'unit_price' => 0, 'subtotal' => 0, 'tax_amount' => 0, 'shipping_charge' => 0, 'discount_total' => 0, 'other_info' => [ 'bundle_parent_product_id' => Arr::get($checkoutItem, 'post_id', 0), 'bundle_parent_variation_id' => Arr::get($checkoutItem, 'object_id', 0) ], ]; //TODO: if bundleItem price is included on for the bundle, then we need to set the price to the bundleItem price // if (Arr::get($bundleItem, 'other_info.is_price_included', 'no') == 'yes') { // $bundleChildItem['unit_price'] = Arr::get($bundleItem, 'unit_price', 0); // $bundleChildItem['subtotal'] = Arr::get($bundleItem, 'subtotal', 0); // $bundleChildItem['tax_amount'] = Arr::get($bundleItem, 'tax_amount', 0); // $bundleChildItem['shipping_charge'] = Arr::get($bundleItem, 'shipping_charge', 0); // $bundleChildItem['discount_total'] = Arr::get($bundleItem, 'discount_total', 0); // } $item['bundle_items'][] = $bundleChildItem; } } $formattedItems[] = $item; if ($childItem) { $formattedItems[] = $childItem; } } $this->formattedIOrderItems = $formattedItems; } private function prepareOrderData() { $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) { return $item['fulfillment_type'] === 'physical'; }); $hasSubscription = array_filter($this->formattedIOrderItems, function ($item) { return $item['payment_type'] === 'subscription'; }); $itemsSubtotal = array_reduce($this->formattedIOrderItems, function ($carry, $item) { if (Arr::get($item, 'other_info.trial_days', 0) > 0) { return $carry; } return $carry + $item['subtotal']; }, 0); $orderData = [ 'status' => Status::ORDER_ON_HOLD, 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL, 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal 'mode' => $this->storeSettings->get('order_mode'), 'shipping_status' => $hasPhysical ? 'unshipped' : '', 'customer_id' => '', 'payment_method' => Arr::get($this->args, 'payment_method', ''), 'payment_status' => Status::PAYMENT_PENDING, 'payment_method_title' => '', 'currency' => $this->storeSettings->get('currency'), 'subtotal' => $itemsSubtotal, 'discount_tax' => 0, 'manual_discount_total' => $this->manualDiscountTotal, 'coupon_discount_total' => $this->couponDiscountTotal, 'shipping_tax' => 0, 'shipping_total' => Arr::get($this->args, 'shipping_total', 0), 'tax_total' => 0, // 'total_amount' => $this->orderTotals['total_amount'], 'total_paid' => 0, 'total_refund' => 0, 'rate' => 1, 'note' => Arr::get($this->args, 'note', ''), 'ip_address' => Arr::get($this->args, 'ip_address', ''), 'config' => [ 'user_tz' => Arr::get($this->args, 'user_tz', ''), 'source' => 'admin', ], ]; $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['shipping_total'] + $orderData['tax_total']; $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0; /** * Filter the prepared order data before it is used for order creation. * * This mirrors the frontend checkout hook so integrations can adjust * order payload fields consistently for manual admin orders too. * * @param array $orderData Prepared order data array. * @param array $context { * Additional context for the filter. * * @type array $items Formatted order items with prices and quantities. * @type array $args Admin order arguments: customer data, payment * method, shipping, tax, notes, and IP data. * } */ $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [ 'items' => $this->formattedIOrderItems, 'args' => $this->args, ]); $this->orderData = $orderData; } public function createDraftOrder($prevOrder = null) { $customerId = Arr::get($this->args, 'customer_id', ''); if (!$customerId) { return new \WP_Error('customer_id_missing', __('Customer ID is required to create a draft order.', 'fluent-cart')); } $orderData = $this->orderData; $orderData['customer_id'] = $customerId; $this->orderModel = \FluentCart\App\Models\Order::query()->create($orderData); if (!$this->orderModel) { return new \WP_Error('order_creation_failed', __('Failed to create order.', 'fluent-cart')); } // Let's create the order items $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) { return $item['payment_type'] != 'signup_fee'; }); foreach ($normalOrderItems as $orderItem) { $orderItem['order_id'] = $this->orderModel->id; $orderItem['line_total'] = $orderItem['subtotal'] - $orderItem['discount_total']; $additionalItems = []; $bundleItems = []; if ($orderItem['payment_type'] == 'subscription') { // this is a subscription type. We may have additional_items $additionalItems = Arr::get($orderItem, 'additional_items', []); unset($orderItem['additional_items']); } if (Arr::get($orderItem, 'other_info.is_bundle_product', 'no') == 'yes') { $bundleItems = Arr::get($orderItem, 'bundle_items', []); unset($orderItem['bundle_items']); } if (!empty($orderItem['coupon_discount'])) { $lineMeta = Arr::get($orderItem, 'line_meta', []); $lineMeta['coupon_discount'] = (int) $orderItem['coupon_discount']; $orderItem['line_meta'] = $lineMeta; } $createdItem = OrderItem::query()->create($orderItem); if ($additionalItems) { $additionalItemIds = []; foreach ($additionalItems as $additionalItem) { $additionalItem['order_id'] = $this->orderModel->id; $additionalItem['line_total'] = $additionalItem['subtotal'] - $additionalItem['discount_total']; $mata = Arr::get($additionalItem, 'line_meta', []); $mata['parent_item_id'] = $createdItem->id; if (!empty($additionalItem['coupon_discount'])) { $mata['coupon_discount'] = (int) $additionalItem['coupon_discount']; } $additionalItem['line_meta'] = $mata; OrderItem::query()->create($additionalItem); } $createdItem->fill([ 'line_meta' => array_merge( $createdItem->line_meta, [ 'additional_item_ids' => $additionalItemIds ] ) ])->save(); } if ($bundleItems) { $bundleItemIds = []; foreach ($bundleItems as $bundleItem) { $bundleItem['order_id'] = $this->orderModel->id; $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0); $bundleItem['payment_type'] = 'bundle'; $meta = Arr::get($bundleItem, 'line_meta', []); $meta['bundle_parent_item_id'] = $createdItem->id; if (!empty($bundleItem['coupon_discount'])) { $meta['coupon_discount'] = (int) $bundleItem['coupon_discount']; } $bundleItem['line_meta'] = $meta; $bundleItem = OrderItem::query()->create($bundleItem); $bundleItemIds[] = $bundleItem->id; } $createdItem->fill([ 'line_meta' => array_merge( $createdItem->line_meta, [ 'bundle_item_ids' => $bundleItemIds ] ) ])->save(); } } // Let's create the subscription if exists if ($this->subscriptionData) { $subscriptionData = $this->subscriptionData; $subscriptionData['customer_id'] = $customerId; $subscriptionData['parent_order_id'] = $this->orderModel->id; $this->subscriptionModel = Subscription::query()->create($subscriptionData); // bill_count counting can't tell "billed cycle" from "something else // charged alongside it." Must be decided at creation: payment-method switching // also sets is_trial_days_simulated, so the flag alone can't be trusted at runtime. $isSimulated = Arr::get($subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes'; $trialDays = (int)Arr::get($subscriptionData, 'trial_days', 0); $billTimes = (int)$this->subscriptionModel->bill_times; $orderTotal = (int)$this->orderModel->total_amount; // Simulated trial, $0 first cycle: consumes a cycle but produces no // total > 0 transaction — add billed_cycles_offset so it still counts. if ($isSimulated && $billTimes > 0 && (int)$this->subscriptionModel->signup_fee <= 0 && !$orderTotal ) { $this->subscriptionModel->updateMeta('billed_cycles_offset', 1); } // Real trial with a signup fee: the initial charge is the signup fee only, // but it IS a total > 0 transaction linked to the subscription — mark // billed_cycles_deduction so it does NOT count as a cycle. if (!$isSimulated && $trialDays > 0 && $billTimes > 0 && $orderTotal > 0 ) { $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1); } } // Let's create the transaction $transactionData = [ 'order_id' => $this->orderModel->id, 'order_type' => $this->orderModel->type, 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE, 'subscription_id' => $this->subscriptionModel ? $this->subscriptionModel->id : NULL, 'payment_method' => $this->orderModel->payment_method, 'payment_mode' => $this->orderModel->mode, 'payment_method_type' => '', 'status' => Status::PAYMENT_PENDING, 'currency' => $this->orderModel->currency, 'total' => $this->orderModel->total_amount, 'rate' => 1, 'meta' => [], ]; $this->transactionModel = \FluentCart\App\Models\OrderTransaction::query()->create($transactionData); // insert the applied coupons $this->insertAppliedCoupons(Arr::get($this->args, 'applied_coupons', [])); $cartHash = Arr::get($this->args, 'cart_hash', ''); if ($cartHash) { $cart = Cart::query()->where('cart_hash', $cartHash)->first(); if ($cart) { $cart->order_id = $this->orderModel->id; $cart->customer_id = $this->orderModel->customer_id; $cart->stage = 'intended'; $cart->save(); $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []); if ($actions) { foreach ($actions as $actionName) { $actionName = (string)$actionName; if (has_action($actionName)) { do_action($actionName, [ 'order' => $this->orderModel, 'cart' => $cart, ]); } } } } } // We are almost done! return $this->orderModel; } private function insertAppliedCoupons($appliedCoupons, $removeOldCoupons = false): void { if ($removeOldCoupons) { $this->orderModel->appliedCoupons()->delete(); } $couponCodes = array_keys($appliedCoupons); if (!empty($couponCodes)) { $coupons = Coupon::query()->whereIn('code', $couponCodes)->get() ->keyBy('code') ->toArray(); foreach ($coupons as $code => &$coupon) { $coupon['coupon_id'] = $appliedCoupons[$code]['id']; $coupon['amount'] = $appliedCoupons[$code]['discount']; } $this->orderModel->appliedCoupons()->createMany($coupons); Coupon::query() ->whereIn('code', $couponCodes) ->increment('use_count', 1); } } public function getTransaction() { return $this->transactionModel; } public function getOrder() { return $this->orderModel; } public function getSubscription() { return $this->subscriptionModel; } private function prepareSubscriptionData() { $subscriptionItems = array_filter($this->formattedIOrderItems, function ($item) { return $item['payment_type'] === 'subscription'; }); $signupFeeItems = array_filter($this->formattedIOrderItems, function ($item) { return $item['payment_type'] === 'signup_fee'; }); if (!$subscriptionItems) { return; } if (count($subscriptionItems) > 1) { return; } $item = reset($subscriptionItems); $signupFeeItem = reset($signupFeeItems) ?? []; $totalSignup = Arr::get($item, 'other_info.signup_fee', 0) - Arr::get($item, 'other_info.signup_discount', 0); $firstPrice = Arr::get($item, 'subtotal') + $totalSignup - Arr::get($item, 'discount_total', 0); $recurringPrice = Arr::get($item, 'subtotal', 0) + Arr::get($item, 'tax_amount', 0); if ($firstPrice < $recurringPrice) { Arr::set($item, 'other_info.trial_days', PaymentHelper::getIntervalDays(Arr::get($item, 'other_info.repeat_interval'))); Arr::set($item, 'other_info.signup_fee', $firstPrice); Arr::set($item, 'other_info.manage_setup_fee', 'yes'); // times stays the full installment count — the simulated trial cycle is installment #1 } else if ($firstPrice > $recurringPrice) { Arr::set($item, 'other_info.signup_fee', $firstPrice - $recurringPrice); Arr::set($item, 'other_info.manage_setup_fee', 'yes'); Arr::set($item, 'other_info.trial_days', 0); } $subscriptionPricing = $this->convertToSubscriptionFormat([ 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0), 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'), 'times' => Arr::get($item, 'other_info.times', 0), 'recurring_amount' => $item['subtotal'], 'signup_fee' => $signupFeeItem ? $signupFeeItem['subtotal'] : 0, 'total_discount' => $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) ]); // removable upon discussion $subscriptionItem = [ 'product_id' => $item['post_id'], 'current_payment_method' => Arr::get($this->orderData, 'payment_method'), 'object_id' => $item['object_id'], 'recurring_tax_total' => 0, 'recurring_total' => $subscriptionPricing['recurring_amount'], //use price not line_total to ignore discount 'item_name' => $item['post_title'] . ' - ' . $item['title'], 'bill_count' => 0, 'quantity' => 1, 'variation_id' => Arr::get($item, 'object_id', 0), 'status' => Status::SUBSCRIPTION_PENDING, 'collection_method' => Status::SUBSCRIPTION_METHOD_MANUAL, 'config' => [ 'currency' => $this->orderData['currency'], 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'), // Snapshot the variant attribute map + variation type from the order // item so the subscription carries the same pa_* set behind its item_name. 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []), 'variation_type' => Arr::get($item, 'other_info.variation_type', ''), ] ]; $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); } /** * @param $inputData * @return array */ private function convertToSubscriptionFormat($inputData) { /** * Normal Subscription $100/month * { * 'trial_days' => 0, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 0 * } * * 30 Days Trial $100/month * { * 'trial_days' => 30, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * } * * $100/month - 30 days Trial with $40 signup fee * { * 'trial_days' => 30, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 40 * } * * $100 / month with $40 signup fee * { * 'trial_days' => 0, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 40 * } * * $100 per month but 30% discount on first month * { * 'trial_days' => 30, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 70 * } * * $100 per month but 50% extra on first month * { * 'trial_days' => 0, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 50 * } * * $100 per month and 1 month trial and service_fee = $50 * { * 'trial_days' => 30, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 50 * } * * * $100 per month, signup fee $200 - First Month 50% discount * * $first Month = 100 + 200 = 300 - 50% discount = 150 * { * 'trial_days' => 30, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 150 * } * * // prefered when signup_fee > recurring_amount * { * 'trial_days' => 0, * 'repeat_interval' => 'month', * 'times' => 0, // 0 means unlimited * 'recurring_amount' => 100, * 'signup_fee' => 50 * } */ // Extract and validate input data $trialDays = (int)($inputData['initial_trial_days'] ?? 0); $repeatInterval = strtolower(trim($inputData['repeat_interval'] ?? 'yearly')); $times = (int)($inputData['times'] ?? 0); $recurringAmount = (int)($inputData['recurring_amount'] ?? 0); $signupFee = (int)($inputData['signup_fee'] ?? 0); $totalDiscount = (int)($inputData['total_discount'] ?? 0); // Convert repeat_interval to standard format $intervalMap = Helper::getAvailableSubscriptionIntervalMaps(); $standardInterval = Helper::translateIntervalToStandardFormat($repeatInterval); // Initialize result array $result = [ 'trial_days' => $trialDays, 'repeat_interval' => $standardInterval, 'times' => $times, 'recurring_amount' => $recurringAmount, ]; // Calculate signup fee logic if ($totalDiscount > 0) { $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount; if ($firstCycleCost < $recurringAmount) { $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval); $result['trial_days'] = $adjustedTrialDays; $result['is_trial_days_simulated'] = 'yes'; $result['signup_fee'] = $firstCycleCost; $result['manage_setup_fee'] = 'yes'; // bill_times stays the full installment count. The simulated trial cycle IS the // first installment; gateways derive remaining remote cycles from is_trial_days_simulated. } else if ($firstCycleCost > $recurringAmount) { $result['trial_days'] = 0; $result['signup_fee'] = $firstCycleCost - $recurringAmount; $result['manage_setup_fee'] = 'yes'; } else if ($firstCycleCost == $recurringAmount) { $result['trial_days'] = 0; $result['signup_fee'] = 0; $result['manage_setup_fee'] = 'no'; } } else { $result['signup_fee'] = $signupFee; } // inverse the repeat interval to match the expected format $result['repeat_interval'] = array_flip($intervalMap)[$result['repeat_interval']] ?? 'yearly'; return [ 'billing_interval' => $result['repeat_interval'], 'bill_times' => $result['times'], 'trial_days' => $result['trial_days'], 'is_trial_days_simulated' => Arr::get($result, 'is_trial_days_simulated', 'no'), 'recurring_amount' => $result['recurring_amount'], 'signup_fee' => $result['signup_fee'] ?? 0, ]; } }