Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
80.10% |
161 / 201 |
|
30.77% |
4 / 13 |
CRAP | |
0.00% |
0 / 1 |
| OrderService | |
80.10% |
161 / 201 |
|
30.77% |
4 / 13 |
55.90 | |
0.00% |
0 / 1 |
| getOrders | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| storeCutomer | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| storeOrder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| storeOrderWithinTransaction | |
87.93% |
51 / 58 |
|
0.00% |
0 / 1 |
7.09 | |||
| resolveShippingCharge | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| posStoreOrder | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| posStoreOrderWithinTransaction | |
97.44% |
38 / 39 |
|
0.00% |
0 / 1 |
6 | |||
| updateOrder | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| transition | |
91.67% |
22 / 24 |
|
0.00% |
0 / 1 |
5.01 | |||
| paymentStore | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| posPaymentStore | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
1.00 | |||
| shippingStore | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getDetails | |
40.48% |
17 / 42 |
|
0.00% |
0 / 1 |
31.09 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Service; |
| 4 | |
| 5 | use App\Enums\OrderStatus; |
| 6 | use App\Models\admin\Customer; |
| 7 | use App\Models\admin\Stock; |
| 8 | use App\Models\Order; |
| 9 | use App\Models\OrderDetails; |
| 10 | use App\Models\Payment; |
| 11 | use App\Models\PaymentType; |
| 12 | use App\Models\ProductVariation; |
| 13 | use App\Services\Cart\CartService; |
| 14 | use App\Services\Cart\CouponService; |
| 15 | use App\Services\Notification\NotificationDispatcher; |
| 16 | use App\Services\Shipping\ShippingService; |
| 17 | use App\Services\Stock\InventoryService; |
| 18 | use Illuminate\Support\Facades\DB; |
| 19 | use Illuminate\Support\Facades\Session; |
| 20 | |
| 21 | class OrderService |
| 22 | { |
| 23 | public static function getOrders() {} |
| 24 | |
| 25 | public static function storeCutomer() |
| 26 | { |
| 27 | // A logged-in customer (Phase 7) checks out as themselves — reuse |
| 28 | // their row instead of creating a duplicate guest customer. |
| 29 | if (auth('customer')->check()) { |
| 30 | return auth('customer')->user(); |
| 31 | } |
| 32 | |
| 33 | $request = request(); |
| 34 | $data = $request->only('name', 'email', 'phone', 'address'); |
| 35 | $customer = Customer::query()->create($data); |
| 36 | |
| 37 | return $customer; |
| 38 | } |
| 39 | |
| 40 | public static function storeOrder($customer) |
| 41 | { |
| 42 | return DB::transaction(fn () => self::storeOrderWithinTransaction($customer)); |
| 43 | } |
| 44 | |
| 45 | private static function storeOrderWithinTransaction($customer) |
| 46 | { |
| 47 | $cart = Session::get('cart', []); |
| 48 | $customerId = $customer->id; |
| 49 | |
| 50 | if (! empty($cart)) { |
| 51 | $total = 0; |
| 52 | foreach (session('cart') as $key => $item) { |
| 53 | $subtotal = $item['qty'] * $item['price']; // 100 = price (পরিবর্তন করুন) |
| 54 | $total += $subtotal; |
| 55 | } |
| 56 | |
| 57 | // SECURITY: never trust the charge posted by the browser. Resolve it |
| 58 | // from the delivery method the customer picked against the configured |
| 59 | // list in Settings → Checkout & Delivery. |
| 60 | $shippingCost = self::resolveShippingCharge(); |
| 61 | |
| 62 | // Coupon + tax — recomputed server-side from the session, never trusted |
| 63 | // from the form. CartService is the one calculator (Phase 7). |
| 64 | $totals = app(CartService::class)->totals($shippingCost); |
| 65 | $couponModel = app(CartService::class)->activeCoupon(); |
| 66 | |
| 67 | $order = new Order; |
| 68 | $order->customer_id = $customerId; |
| 69 | $order->total = $total; |
| 70 | $order->discount = $totals->discount; |
| 71 | $order->tax = $totals->tax; |
| 72 | $order->coupon_code = $totals->couponCode; |
| 73 | $order->shipping_cost = $shippingCost; |
| 74 | $order->due = $totals->grandTotal(); |
| 75 | |
| 76 | // Reference number: {prefix}YYYYMMDD-XXXXX |
| 77 | $prefix = settings('checkout.order_number_prefix', 'ORD-'); |
| 78 | $dateCode = date('Ymd'); |
| 79 | $randomCode = strtoupper(substr(uniqid(), -5)); |
| 80 | $referenceNumber = $prefix . $dateCode . '-' . $randomCode; |
| 81 | |
| 82 | $order->bar_code = $referenceNumber; |
| 83 | $order->qr_code = $referenceNumber; |
| 84 | $order->save(); |
| 85 | |
| 86 | // **ধাপ ২: `order_details` টেবিলে প্রতিটি অর্ডারের পণ্য যুক্ত করা** |
| 87 | foreach ($cart as $item) { |
| 88 | $stockId = $item['stock_id']; |
| 89 | |
| 90 | // One lookup serves both the stock_out increment and the |
| 91 | // marketplace vendor stamp below. Null for synthetic |
| 92 | // (non-numeric) cart lines. |
| 93 | $stock = is_numeric($stockId) ? Stock::query()->find($stockId) : null; |
| 94 | |
| 95 | $line = OrderDetails::query()->create([ |
| 96 | 'order_id' => $order->id, |
| 97 | 'stock_id' => $stockId, |
| 98 | // Marketplace: stamp the owning vendor so commissions can be |
| 99 | // recorded on payment. Null for platform-owned products and |
| 100 | // whenever multi_vendor is off (nothing sets product vendor_id). |
| 101 | 'vendor_id' => $stock?->product?->vendor_id, |
| 102 | 'qty' => $item['qty'], |
| 103 | 'price' => $item['price'], |
| 104 | 'discount' => 0, |
| 105 | 'total' => $item['price'] * $item['qty'], |
| 106 | ]); |
| 107 | |
| 108 | app(InventoryService::class)->deduct($line); |
| 109 | } |
| 110 | // Record the coupon redemption + bump its usage count. |
| 111 | if ($couponModel && $totals->discount > 0) { |
| 112 | app(CouponService::class)->redeem( |
| 113 | $couponModel, |
| 114 | $totals->discount, |
| 115 | $order->id, |
| 116 | $customerId, |
| 117 | $customer->email ?? null, |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | // **সেশন থেকে কার্ট মুছে ফেলা** |
| 122 | Session::forget('cart'); |
| 123 | Session::forget('shipping_cost'); |
| 124 | Session::forget('coupon_code'); |
| 125 | |
| 126 | // Notification matrix (Phase 8) — a no-op on a fresh install: |
| 127 | // every event's channel list is empty until the operator opts |
| 128 | // in from Settings → Notification Settings. |
| 129 | app(NotificationDispatcher::class)->dispatch('order_placed', [ |
| 130 | 'email' => $customer->email ?? null, |
| 131 | 'phone' => $customer->phone ?? null, |
| 132 | ], [ |
| 133 | 'customer_name' => $customer->name ?? 'Customer', |
| 134 | 'order_number' => $order->bar_code, |
| 135 | 'order_total' => money($order->grandTotal()), |
| 136 | ]); |
| 137 | |
| 138 | return $order; |
| 139 | |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * The delivery charge for this checkout, resolved server-side — never |
| 145 | * trusted from the browser. Prefers a real shipping zone/rate (Phase 7 |
| 146 | * Slice 3) when the store has any set up; otherwise falls back to the |
| 147 | * flat method list in Settings → Checkout & Delivery, exactly as before. |
| 148 | */ |
| 149 | public static function resolveShippingCharge(): float |
| 150 | { |
| 151 | $shipping = app(ShippingService::class); |
| 152 | |
| 153 | if ($shipping->hasZones() && request()->filled('shipping_rate_id')) { |
| 154 | $subtotal = collect(Session::get('cart', []))->sum(fn ($i) => ($i['qty'] ?? 1) * ($i['price'] ?? 0)); |
| 155 | |
| 156 | return $shipping->resolveCharge((int) request('shipping_rate_id'), (float) $subtotal); |
| 157 | } |
| 158 | |
| 159 | $methods = collect(settings('checkout.shipping_methods', [])) |
| 160 | ->filter(fn ($m) => ! empty($m['label'])) |
| 161 | ->values(); |
| 162 | |
| 163 | $picked = request('shipping_method'); |
| 164 | |
| 165 | if ($picked !== null && $methods->has((int) $picked)) { |
| 166 | return (float) ($methods[(int) $picked]['charge'] ?? 0); |
| 167 | } |
| 168 | |
| 169 | // legacy fallbacks (old drawer flow stored the value in the session) |
| 170 | return (float) (Session::get('shipping_cost') ?? request('shipping_charge') ?? 0); |
| 171 | } |
| 172 | |
| 173 | public static function posStoreOrder($customer) |
| 174 | { |
| 175 | return DB::transaction(fn () => self::posStoreOrderWithinTransaction($customer)); |
| 176 | } |
| 177 | |
| 178 | private static function posStoreOrderWithinTransaction($customer) |
| 179 | { |
| 180 | $stockIds = request()->input('stock_id'); // array |
| 181 | $qtys = request()->input('qty'); |
| 182 | $customerId = $customer; |
| 183 | |
| 184 | // An empty cart used to fall off the end and return null — the caller |
| 185 | // then did $order->update() on null (fatal). Reject it clearly instead; |
| 186 | // SaleController surfaces a RuntimeException's message verbatim. |
| 187 | if (empty($stockIds)) { |
| 188 | throw new \RuntimeException('Add at least one item before saving the sale.'); |
| 189 | } |
| 190 | |
| 191 | if (! empty($stockIds)) { |
| 192 | // FIX: this used to trust request()->total outright (trivially |
| 193 | // tamperable from devtools before submit — nothing server-side ever |
| 194 | // recomputed it), and further down had a dead `$total = $qty * $qty` |
| 195 | // line that was assigned but never actually applied to the order. |
| 196 | // Now the total is built here from each stock's real selling_price, |
| 197 | // and — since we're already touching every line item — this also |
| 198 | // checks real available stock (stock_in - stock_out) before |
| 199 | // committing anything, so the POS can't oversell an item that ran |
| 200 | // out between the page loading and the sale being submitted. |
| 201 | $lineItems = []; |
| 202 | $total = 0; |
| 203 | foreach ($stockIds as $index => $stockId) { |
| 204 | $qty = app(InventoryService::class)->quantity($qtys[$index] ?? null); |
| 205 | $stock = Stock::query()->with('product')->findOrFail($stockId); |
| 206 | app(InventoryService::class)->assertAvailable($stockId, $qty); |
| 207 | |
| 208 | // Stock.selling_price is variant-aware (StockService sets it to |
| 209 | // the ProductVariation's own price when there is one) — the |
| 210 | // parent product's selling_price ignores per-variant pricing, |
| 211 | // so the recorded line total diverged from what the cashier saw. |
| 212 | $price = (float) ($stock->selling_price ?: ($stock->product->selling_price ?? 0)); |
| 213 | $lineItems[] = ['stock' => $stock, 'qty' => $qty, 'price' => $price]; |
| 214 | $total += $price * $qty; |
| 215 | } |
| 216 | |
| 217 | $shipping = (float) (request()->shipping_cost ?? 0); |
| 218 | $paid = (float) (request()->paid ?? 0); |
| 219 | |
| 220 | $order = new Order; |
| 221 | $order->customer_id = $customerId; |
| 222 | $order->total = $total; |
| 223 | $order->shipping_cost = $shipping; |
| 224 | // due is against the grand total (items + shipping), not items alone. |
| 225 | $order->due = max(0, ($total + $shipping) - $paid); |
| 226 | |
| 227 | // Generate unique reference number: ORD-YYYYMMDD-XXXXX |
| 228 | $dateCode = date('Ymd'); |
| 229 | $randomCode = strtoupper(substr(uniqid(), -5)); |
| 230 | $referenceNumber = 'ORD-' . $dateCode . '-' . $randomCode; |
| 231 | |
| 232 | $order->bar_code = $referenceNumber; |
| 233 | $order->qr_code = $referenceNumber; |
| 234 | $order->save(); |
| 235 | |
| 236 | // **ধাপ ২: `order_items` টেবিলে প্রতিটি অর্ডারের পণ্য যুক্ত করা** |
| 237 | foreach ($lineItems as $item) { |
| 238 | $line = OrderDetails::create([ |
| 239 | 'order_id' => $order->id, |
| 240 | 'stock_id' => $item['stock']->id, |
| 241 | // Marketplace: stamp the owning vendor (null when platform-owned |
| 242 | // or multi_vendor is off) so payment can record commissions. |
| 243 | 'vendor_id' => $item['stock']->product?->vendor_id, |
| 244 | 'qty' => $item['qty'], |
| 245 | 'price' => $item['price'], |
| 246 | 'total' => $item['price'] * $item['qty'], |
| 247 | ]); |
| 248 | app(InventoryService::class)->deduct($line); |
| 249 | } |
| 250 | |
| 251 | return $order; |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | public static function updateOrder($payload) {} |
| 256 | |
| 257 | /** |
| 258 | * Move an order to a new status. Rejects anything that isn't a real |
| 259 | * OrderStatus value (see App\Enums\OrderStatus) instead of writing |
| 260 | * whatever string the caller passed — closes a real gap in |
| 261 | * OrderController::changeStatus(), which used to save the raw request |
| 262 | * value with no validation at all. No transition table is enforced |
| 263 | * (e.g. Delivered -> Pending isn't blocked); every change is still |
| 264 | * captured by Order's Auditable trait. |
| 265 | * |
| 266 | * @throws \InvalidArgumentException if $status isn't a valid OrderStatus value |
| 267 | */ |
| 268 | public static function transition(Order $order, string $status): Order |
| 269 | { |
| 270 | $enum = OrderStatus::tryFrom($status); |
| 271 | |
| 272 | if (! $enum) { |
| 273 | throw new \InvalidArgumentException("\"{$status}\" is not a valid order status."); |
| 274 | } |
| 275 | |
| 276 | DB::transaction(function () use ($order, $enum) { |
| 277 | $locked = Order::lockForUpdate()->findOrFail($order->id); |
| 278 | $terminal = ['Cancelled', 'Returned']; |
| 279 | if (in_array($locked->status, ['Cancelled', 'Returned', 'Refunded'], true) |
| 280 | && ! in_array($enum->value, ['Cancelled', 'Returned', 'Refunded'], true)) { |
| 281 | throw new \InvalidArgumentException('A cancelled or returned order cannot be reopened. Create a new order.'); |
| 282 | } |
| 283 | if (in_array($enum->value, $terminal, true)) { |
| 284 | app(InventoryService::class)->restoreOrder($locked); |
| 285 | } |
| 286 | $locked->status = $enum->value; |
| 287 | $locked->save(); |
| 288 | }); |
| 289 | $order->refresh(); |
| 290 | |
| 291 | app(NotificationDispatcher::class)->dispatch('order_status_changed', [ |
| 292 | 'email' => $order->customer->email ?? null, |
| 293 | 'phone' => $order->customer->phone ?? null, |
| 294 | ], [ |
| 295 | 'customer_name' => $order->customer->name ?? 'Customer', |
| 296 | 'order_number' => $order->bar_code, |
| 297 | 'status' => $enum->value, |
| 298 | ]); |
| 299 | |
| 300 | return $order; |
| 301 | } |
| 302 | |
| 303 | public static function paymentStore($order) |
| 304 | { |
| 305 | return Payment::create([ |
| 306 | 'order_id' => $order->id, |
| 307 | 'paid' => 0, |
| 308 | 'payment_type' => 'Cash on delivery', |
| 309 | 'payment_note' => 'Payment pending', |
| 310 | ]); |
| 311 | } |
| 312 | |
| 313 | public static function posPaymentStore($order) |
| 314 | { |
| 315 | // FIX: payment_type/payment_note/customer_id were hardcoded and used to be |
| 316 | // silently dropped anyway (Payment::$fillable didn't list them). Now that |
| 317 | // $fillable includes them, resolve the real payment type the cashier picked |
| 318 | // on the POS form instead of always writing "Cash on delivery". |
| 319 | $paymentTypeName = optional(PaymentType::find(request()->payment_type_id))->name |
| 320 | ?? 'Cash on delivery'; |
| 321 | |
| 322 | $pay = Payment::create([ |
| 323 | 'order_id' => $order->id, |
| 324 | 'customer_id' => $order->customer_id, |
| 325 | 'paid' => (float) (request()->paid ?? 0), |
| 326 | 'payment_type' => $paymentTypeName, |
| 327 | 'payment_note' => 'Initial payment at sale', |
| 328 | ]); |
| 329 | |
| 330 | return $pay; |
| 331 | } |
| 332 | |
| 333 | public static function shippingStore($payload) |
| 334 | { |
| 335 | return true; |
| 336 | } |
| 337 | |
| 338 | public static function getDetails($id) |
| 339 | { |
| 340 | $details = OrderDetails::where('order_id', $id)->get(); |
| 341 | $result = []; |
| 342 | |
| 343 | foreach ($details as $item) { |
| 344 | $isVariation = str_starts_with($item->stock_id, 'var_'); |
| 345 | |
| 346 | if ($isVariation) { |
| 347 | $varId = str_replace('var_', '', $item->stock_id); |
| 348 | $variation = ProductVariation::with('product', 'attributeValues.attribute')->find($varId); |
| 349 | |
| 350 | $color = null; |
| 351 | $size = null; |
| 352 | $attrs = []; |
| 353 | |
| 354 | if ($variation && $variation->attributeValues) { |
| 355 | foreach ($variation->attributeValues as $av) { |
| 356 | $attrName = strtolower($av->attribute->name ?? ''); |
| 357 | if ($attrName == 'color') { |
| 358 | $color = $av->value; |
| 359 | } elseif ($attrName == 'size') { |
| 360 | $size = $av->value; |
| 361 | } else { |
| 362 | $attrs[] = $av->value; |
| 363 | } |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | // If standard color/size not found, use any other attributes as color string just to show them |
| 368 | if (! $color && count($attrs) > 0) { |
| 369 | $color = implode(', ', $attrs); |
| 370 | } |
| 371 | |
| 372 | $result[] = (object) [ |
| 373 | 'order_id' => $item->order_id, |
| 374 | 'order_qty' => $item->qty, |
| 375 | 'order_price' => $item->price, |
| 376 | 'stock_id' => $item->stock_id, |
| 377 | 'color' => $color, |
| 378 | 'size' => $size, |
| 379 | 'image' => $variation->product->thumbnail ?? '', |
| 380 | 'product_name' => $variation->product->name ?? 'Unknown Variation', |
| 381 | ]; |
| 382 | } else { |
| 383 | $stock = Stock::with('product', 'color', 'size')->find($item->stock_id); |
| 384 | |
| 385 | $result[] = (object) [ |
| 386 | 'order_id' => $item->order_id, |
| 387 | 'order_qty' => $item->qty, |
| 388 | 'order_price' => $item->price, |
| 389 | 'stock_id' => $item->stock_id, |
| 390 | 'color' => $stock->color->name ?? '', |
| 391 | 'size' => $stock->size->name ?? '', |
| 392 | 'image' => $stock->product->thumbnail ?? '', |
| 393 | 'product_name' => $stock->product->name ?? 'Unknown Product', |
| 394 | ]; |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | return collect($result); |
| 399 | } |
| 400 | } |