Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.01% covered (success)
97.01%
65 / 67
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CartService
97.01% covered (success)
97.01%
65 / 67
80.00% covered (warning)
80.00%
8 / 10
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 items
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 isEmpty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 subtotal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 itemCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activeCoupon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 applyCoupon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 clearCoupon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 totals
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 taxFor
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
9
1<?php
2
3namespace App\Services\Cart;
4
5use App\Models\Category;
6use App\Models\Coupon;
7use Illuminate\Support\Facades\Session;
8
9/**
10 * The single source of truth for what a cart costs. Reads the existing
11 * `cart` session structure (keyed by stock id) — it does NOT change how
12 * items are added or removed. Coupons + tax + shipping all fold in here so
13 * the cart drawer, checkout, order creation and the API agree.
14 */
15class CartService
16{
17    public function __construct(private CouponService $coupons) {}
18
19    /** @return array<int,array{stock_id:mixed,qty:int,price:float,name:string,category_id:?int,line_total:float}> */
20    public function items(): array
21    {
22        return collect(Session::get('cart', []))
23            ->map(fn ($row) => [
24                'stock_id' => $row['stock_id'] ?? null,
25                'qty' => (int) ($row['qty'] ?? 1),
26                'price' => (float) ($row['price'] ?? 0),
27                'name' => $row['name'] ?? '',
28                'color' => $row['color'] ?? null,
29                'size' => $row['size'] ?? null,
30                'image' => $row['image'] ?? null,
31                // Per-category tax override (Phase 7) — absent on carts started
32                // before this shipped, which just means "use the store rate".
33                'category_id' => $row['category_id'] ?? null,
34                'line_total' => (float) ($row['price'] ?? 0) * (int) ($row['qty'] ?? 1),
35            ])
36            ->values()
37            ->all();
38    }
39
40    public function isEmpty(): bool
41    {
42        return $this->items() === [];
43    }
44
45    public function subtotal(): float
46    {
47        return round(collect($this->items())->sum('line_total'), 2);
48    }
49
50    public function itemCount(): int
51    {
52        return (int) collect($this->items())->sum('qty');
53    }
54
55    /** The coupon currently stored on the session, if it still validates. */
56    public function activeCoupon(): ?Coupon
57    {
58        $code = Session::get('coupon_code');
59        if (! $code) {
60            return null;
61        }
62
63        [$coupon] = $this->coupons->resolve($code, $this->subtotal());
64
65        return $coupon;
66    }
67
68    public function applyCoupon(string $code, ?int $customerId = null, ?string $email = null): array
69    {
70        [$coupon, $error] = $this->coupons->resolve($code, $this->subtotal(), $customerId, $email);
71
72        if (! $coupon) {
73            return [false, $error];
74        }
75
76        Session::put('coupon_code', $coupon->code);
77
78        return [true, null];
79    }
80
81    public function clearCoupon(): void
82    {
83        Session::forget('coupon_code');
84    }
85
86    /**
87     * Compute every line of the total.
88     *
89     * @param  float|null  $shipping  resolved delivery charge (defaults to the session value)
90     */
91    public function totals(?float $shipping = null): CartTotals
92    {
93        $subtotal = $this->subtotal();
94        $coupon = $this->activeCoupon();
95        $discount = $coupon ? $coupon->discountFor($subtotal) : 0.0;
96
97        $shipping ??= (float) (Session::get('shipping_cost') ?? 0);
98
99        $storeTaxRate = (float) settings('checkout.tax_rate', 0);
100        $taxInclusive = (bool) settings('checkout.tax_inclusive', false);
101        $tax = 0.0;
102
103        if (feature('tax_vat')) {
104            $tax = $this->taxFor($subtotal, $discount, $storeTaxRate, $taxInclusive);
105        }
106
107        return new CartTotals(
108            subtotal: $subtotal,
109            discount: $discount,
110            tax: $tax,
111            shipping: $shipping,
112            itemCount: $this->itemCount(),
113            couponCode: $coupon?->code,
114            taxInclusive: $taxInclusive,
115        );
116    }
117
118    /**
119     * Tax, computed per line so a per-category override (Phase 7) only
120     * affects the lines it applies to. The coupon discount is allocated
121     * across lines proportionally to their share of the subtotal, so a
122     * cart mixing a discounted rate-A category with an undiscounted
123     * rate-B one still taxes each line's actual net price. With no
124     * category overrides at all, every line uses $storeTaxRate and this
125     * collapses to exactly the old single-rate calculation.
126     */
127    private function taxFor(float $subtotal, float $discount, float $storeTaxRate, bool $taxInclusive): float
128    {
129        $items = $this->items();
130        if ($items === []) {
131            return 0.0;
132        }
133
134        $categoryIds = collect($items)->pluck('category_id')->filter()->unique();
135        $overrides = $categoryIds->isEmpty()
136            ? collect()
137            : Category::whereIn('id', $categoryIds)->whereNotNull('tax_rate')->pluck('tax_rate', 'id');
138
139        $tax = 0.0;
140        foreach ($items as $item) {
141            $rate = $item['category_id'] && $overrides->has($item['category_id'])
142                ? (float) $overrides->get($item['category_id'])
143                : $storeTaxRate;
144
145            if ($rate <= 0) {
146                continue;
147            }
148
149            $share = $subtotal > 0 ? $item['line_total'] / $subtotal : 0;
150            $taxable = max(0, $item['line_total'] - ($discount * $share));
151
152            $tax += $taxInclusive
153                ? ($taxable - ($taxable / (1 + $rate / 100)))
154                : ($taxable * $rate / 100);
155        }
156
157        return round($tax, 2);
158    }
159}