Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.10% covered (warning)
80.10%
161 / 201
30.77% covered (danger)
30.77%
4 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
OrderService
80.10% covered (warning)
80.10%
161 / 201
30.77% covered (danger)
30.77%
4 / 13
55.90
0.00% covered (danger)
0.00%
0 / 1
 getOrders
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeCutomer
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 storeOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storeOrderWithinTransaction
87.93% covered (warning)
87.93%
51 / 58
0.00% covered (danger)
0.00%
0 / 1
7.09
 resolveShippingCharge
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 posStoreOrder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 posStoreOrderWithinTransaction
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
6
 updateOrder
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 transition
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
5.01
 paymentStore
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 posPaymentStore
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
1.00
 shippingStore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDetails
40.48% covered (danger)
40.48%
17 / 42
0.00% covered (danger)
0.00%
0 / 1
31.09
1<?php
2
3namespace App\Service;
4
5use App\Enums\OrderStatus;
6use App\Models\admin\Customer;
7use App\Models\admin\Stock;
8use App\Models\Order;
9use App\Models\OrderDetails;
10use App\Models\Payment;
11use App\Models\PaymentType;
12use App\Models\ProductVariation;
13use App\Services\Cart\CartService;
14use App\Services\Cart\CouponService;
15use App\Services\Notification\NotificationDispatcher;
16use App\Services\Shipping\ShippingService;
17use App\Services\Stock\InventoryService;
18use Illuminate\Support\Facades\DB;
19use Illuminate\Support\Facades\Session;
20
21class 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}