| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\App\Helpers; |
| 4 | 4 | |
| 5 | +use FluentCart\Api\Resource\OrderResource; | |
| 6 | +use FluentCart\App\Models\Order; | |
| 5 | 7 | use FluentCart\App\Models\OrderItem; |
| 6 | 8 | use FluentCart\Framework\Support\Arr; |
| 7 | 9 | |
| 8 | 10 | class OrderItemHelper |
| @@ -103,34 +105,93 @@ | ||
| 103 | 105 | |
| 104 | 106 | |
| 105 | 107 | public function processCustom($product, $orderId) |
| 106 | 108 | { |
| 107 | - $price = Arr::get($product, 'item_price', false); | |
| 108 | - $quantity = Arr::get($product, 'quantity', false); | |
| 109 | + if (!$orderId) { | |
| 110 | + throw new \Exception(esc_html__('Order Not valid!', 'fluent-cart')); | |
| 111 | + } | |
| 109 | 112 | |
| 110 | - if (!Arr::get($product, 'item_name', false)) { | |
| 113 | + // The published spec (openapi/orders/create-custom-order-item.json) names | |
| 114 | + // these `title` and `price`; the original implementation read `item_name` | |
| 115 | + // and `item_price`. Accept both so neither contract is broken. | |
| 116 | + $title = sanitize_text_field((string)Arr::get($product, 'title', Arr::get($product, 'item_name', ''))); | |
| 117 | + | |
| 118 | + if (!$title) { | |
| 111 | 119 | throw new \Exception(esc_html__('Item must have a name!', 'fluent-cart')); |
| 112 | 120 | } |
| 113 | 121 | |
| 114 | - if (!$price || !$quantity) { | |
| 122 | + // Price arrives already in cents. | |
| 123 | + $unitPrice = Helper::roundCent(Arr::get($product, 'price', Arr::get($product, 'item_price', 0))); | |
| 124 | + $quantity = intval(Arr::get($product, 'quantity', 0)); | |
| 125 | + | |
| 126 | + if ($unitPrice <= 0 || $quantity <= 0) { | |
| 115 | 127 | throw new \Exception(esc_html__('Price, Quantity field should not be empty or zero!', 'fluent-cart')); |
| 116 | 128 | } |
| 117 | 129 | |
| 118 | - //$total = floatVal($price * 100 * $quantity); | |
| 119 | - $total = floatVal($price * $quantity); | |
| 130 | + $lineTotal = $unitPrice * $quantity; | |
| 120 | 131 | |
| 121 | - $type = Arr::get($product, 'fulfillment_type', 'physical'); | |
| 132 | + $fulfillmentType = Arr::get($product, 'fulfillment_type', Status::FULFILLMENT_TYPE_PHYSICAL); | |
| 122 | 133 | |
| 123 | - $otherData = [ | |
| 124 | - 'order_id' => $orderId, | |
| 125 | - 'variation_type' => $type, | |
| 126 | - //'item_price' => $price * 100, | |
| 127 | - 'item_price' => $price, | |
| 128 | - 'item_total' => $total, | |
| 129 | - 'line_total' => $total, | |
| 130 | - 'tax_amount' => 0, | |
| 131 | - 'discount_total' => 0, | |
| 132 | - ]; | |
| 134 | + if (!in_array($fulfillmentType, [Status::FULFILLMENT_TYPE_PHYSICAL, Status::FULFILLMENT_TYPE_DIGITAL], true)) { | |
| 135 | + $fulfillmentType = Status::FULFILLMENT_TYPE_PHYSICAL; | |
| 136 | + } | |
| 133 | 137 | |
| 134 | - return $this->sanitize(array_merge($product, $otherData)); | |
| 138 | + $order = Order::query()->find(intval($orderId)); | |
| 139 | + | |
| 140 | + if (!$order) { | |
| 141 | + throw new \Exception(esc_html__('Order Not valid!', 'fluent-cart')); | |
| 142 | + } | |
| 143 | + | |
| 144 | + // Same rule as OrderController::updateOrder — a subscription order's | |
| 145 | + // total is bound to its pending charge, which nothing on this path | |
| 146 | + // resynchronizes. | |
| 147 | + if ($order->isSubscription()) { | |
| 148 | + throw new \Exception(esc_html__('Subscription Order cannot be edited.', 'fluent-cart')); | |
| 149 | + } | |
| 150 | + | |
| 151 | + $db = Order::query()->getConnection(); | |
| 152 | + $db->beginTransaction(); | |
| 153 | + | |
| 154 | + try { | |
| 155 | + // Serialize on the order row so concurrent additions rebuild the | |
| 156 | + // aggregates one at a time, and a failed rebuild rolls the line | |
| 157 | + // back out. The tax pass opens its own transaction inside this | |
| 158 | + // one, which the connection runs as a savepoint. | |
| 159 | + $locked = Order::query() | |
| 160 | + ->where('id', $order->id) | |
| 161 | + ->lockForUpdate() | |
| 162 | + ->first(); | |
| 163 | + | |
| 164 | + if (!$locked) { | |
| 165 | + throw new \Exception(esc_html__('Order Not valid!', 'fluent-cart')); | |
| 166 | + } | |
| 167 | + | |
| 168 | + $orderItem = OrderItem::create([ | |
| 169 | + 'order_id' => $locked->id, | |
| 170 | + 'fulfillment_type' => $fulfillmentType, | |
| 171 | + 'title' => $title, | |
| 172 | + 'post_title' => $title, | |
| 173 | + 'quantity' => $quantity, | |
| 174 | + 'unit_price' => $unitPrice, | |
| 175 | + 'subtotal' => $lineTotal, | |
| 176 | + 'line_total' => $lineTotal, | |
| 177 | + 'tax_amount' => 0, | |
| 178 | + 'discount_total' => 0, | |
| 179 | + // Same rule as the order-edit insert path: digital lines need | |
| 180 | + // no shipment, so they are born fulfilled. | |
| 181 | + 'fulfilled_quantity' => $fulfillmentType === Status::FULFILLMENT_TYPE_PHYSICAL ? 0 : $quantity, | |
| 182 | + ]); | |
| 183 | + | |
| 184 | + // A line added on its own leaves the order carrying the subtotal it | |
| 185 | + // had before that line existed; the whole-order save posts | |
| 186 | + // client-computed totals instead and never reaches here. | |
| 187 | + OrderResource::syncItemDerivedTotals($locked); | |
| 188 | + | |
| 189 | + $db->commit(); | |
| 190 | + } catch (\Exception $e) { | |
| 191 | + $db->rollBack(); | |
| 192 | + throw $e; | |
| 193 | + } | |
| 194 | + | |
| 195 | + return $orderItem; | |
| 135 | 196 | } |
| 136 | 197 | } |