Passed
Push — main ( 144555...8e5edc )
by Dimitri
54s queued 14s
created

Cart::createCartItem()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 21
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 7

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 14
c 1
b 0
f 0
nc 4
nop 5
dl 0
loc 21
ccs 10
cts 10
cp 1
crap 7
rs 8.8333
1
<?php
2
3
/**
4
 * This file is part of dimtrovich/cart".
5
 *
6
 * (c) 2024 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace Dimtrovich\Cart;
13
14
use BlitzPHP\Contracts\Event\EventManagerInterface;
15
use BlitzPHP\Utilities\Iterable\Arr;
16
use BlitzPHP\Utilities\Iterable\Collection;
17
use Closure;
18
use Dimtrovich\Cart\Contracts\Buyable;
19
use Dimtrovich\Cart\Contracts\StoreManager;
20
use Dimtrovich\Cart\Exceptions\InvalidRowIDException;
21
use Dimtrovich\Cart\Handlers\Session;
22
use InvalidArgumentException;
23
use RuntimeException;
24
25
/**
26
 * Cart processor
27
 *
28
 * @property float|int $subtotal Subtotal (total - tax) of the items in the cart.
29
 * @property float|int $tax      Total tax of the items in the cart.
30
 * @property float|int $total    Total price of the items in the cart.
31
 */
32
class Cart
33
{
34
    public const DEFAULT_INSTANCE = 'default';
35
36
    /**
37
     * Holds the current cart instance.
38
     */
39
    private string $instance;
40
41
    /**
42
     * Cart constructor.
43
     *
44
     * @param array<string, mixed>  $config Configuration of cart instance
45
     * @param ?StoreManager         $store  Instance of the session manager.
46
     * @param EventManagerInterface $event  Instance of the event manager
47
     */
48
    public function __construct(private array $config = [], private ?StoreManager $store = null, private ?EventManagerInterface $event = null)
49
    {
50
        $this->config += [
51
            'handler' => Session::class,
52
            'tax'     => 20,
53 3
        ];
54
55 3
        $this->instance(self::DEFAULT_INSTANCE);
56
    }
57
58
    /**
59
     * Set the current cart instance.
60
     */
61
    public function instance(?string $instance = null): self
62
    {
63 3
        $instance = $instance ?: self::DEFAULT_INSTANCE;
64
65 3
        $this->instance = sprintf('%s.%s', 'cart', $instance);
66
67 3
        $this->initStore();
68
69 3
        return $this;
70
    }
71
72
    /**
73
     * Get the current cart instance.
74
     */
75
    public function currentInstance(): string
76
    {
77 3
        return str_replace('cart.', '', $this->instance);
78
    }
79
80
    /**
81
     * Add an item to the cart.
82
     *
83
     * @param array<string, mixed>|float|int|null $qty
84
     * @param array<string, mixed>                $options
85
     *
86
     * @return CartItem|CartItem[]
87
     */
88
    public function add(mixed $id, mixed $name = null, null|array|float|int $qty = null, ?float $price = null, array $options = [])
89
    {
90
        if ($this->isMulti($id)) {
91 1
            return array_map(fn ($item) => $this->add($item), $id);
92
        }
93
94 3
        $cartItem = $this->createCartItem($id, $name, $qty, $price, $options);
95
96 3
        $content = $this->getContent();
97
98
        if ($content->has($cartItem->rowId)) {
99 1
            $cartItem->qty += $content->get($cartItem->rowId)->qty;
100
        }
101
102 3
        $content->put($cartItem->rowId, $cartItem);
103
104 3
        $this->emit('cart.added', $cartItem);
105
106 3
        $this->store->put($content);
107
108 3
        return $cartItem;
109
    }
110
111
    /**
112
     * Update the cart item with the given rowId.
113
     */
114
    public function update(string $rowId, mixed $qty): ?CartItem
115
    {
116 1
        $cartItem = $this->get($rowId);
117
118
        if ($qty instanceof Buyable) {
119 1
            $cartItem->updateFromBuyable($qty);
120
        } elseif (is_array($qty)) {
121 1
            $cartItem->updateFromArray($qty);
122
        } else {
123 1
            $cartItem->qty = $qty;
124
        }
125
126 1
        $content = $this->getContent();
127
128
        if ($rowId !== $cartItem->rowId) {
129 1
            $content->pull($rowId);
130
131
            if ($content->has($cartItem->rowId)) {
132 1
                $existingCartItem = $this->get($cartItem->rowId);
133 1
                $cartItem->setQuantity($existingCartItem->qty + $cartItem->qty);
134
            }
135
        }
136
137
        if ($cartItem->qty <= 0) {
138 1
            $this->remove($cartItem->rowId);
139
140 1
            return null;
141
        }
142 1
        $content->put($cartItem->rowId, $cartItem);
143
144 1
        $this->emit('cart.updated', $cartItem);
145
146 1
        $this->store->put($content);
147
148 1
        return $cartItem;
149
    }
150
151
    /**
152
     * Remove the cart item with the given rowId from the cart.
153
     */
154
    public function remove(string $rowId): void
155
    {
156 1
        $cartItem = $this->get($rowId);
157
158 1
        $content = $this->getContent();
159
160 1
        $content->pull($cartItem->rowId);
161
162 1
        $this->emit('cart.removed', $cartItem);
163
164 1
        $this->store->put($content);
165
    }
166
167
    /**
168
     * Get a cart item from the cart by its rowId.
169
     */
170
    public function get(string $rowId): CartItem
171
    {
172 1
        $content = $this->getContent();
173
174
        if (! $content->has($rowId)) {
175 1
            throw new InvalidRowIDException("The cart does not contain rowId {$rowId}.");
176
        }
177
178 1
        return $content->get($rowId);
179
    }
180
181
    /**
182
     * Destroy the current cart instance.
183
     */
184
    public function destroy(): void
185
    {
186 3
        $this->store->remove();
187
    }
188
189
    /**
190
     * Get the content of the cart.
191
     */
192
    public function content(): Collection
193
    {
194 1
        return $this->getContent();
195
    }
196
197
    /**
198
     * Get the number of items in the cart.
199
     *
200
     * @return float|int
201
     */
202
    public function count()
203
    {
204 3
        $content = $this->getContent();
205
206 3
        return $content->sum('qty');
207
    }
208
209
    /**
210
     * Get the total price of the items in the cart.
211
     *
212
     * @return float|string
213
     */
214
    public function total(?int $decimals = null, ?string $decimalPoint = null, ?string $thousandSeperator = null)
215
    {
216 1
        $content = $this->getContent();
217
218 1
        $total = $content->reduce(fn ($total, CartItem $cartItem) => $total + ($cartItem->qty * $cartItem->priceTax), 0);
219
220 1
        return func_num_args() < 2 ? $total : $this->numberFormat($total, $decimals, $decimalPoint, $thousandSeperator);
221
    }
222
223
    /**
224
     * Get the total tax of the items in the cart.
225
     *
226
     * @return float|string
227
     */
228
    public function tax(?int $decimals = null, ?string $decimalPoint = null, ?string $thousandSeperator = null)
229
    {
230 1
        $content = $this->getContent();
231
232 1
        $tax = $content->reduce(fn ($tax, CartItem $cartItem) => $tax + ($cartItem->qty * $cartItem->tax), 0);
233
234 1
        return func_num_args() < 2 ? $tax : $this->numberFormat($tax, $decimals, $decimalPoint, $thousandSeperator);
235
    }
236
237
    /**
238
     * Get the subtotal (total - tax) of the items in the cart.
239
     *
240
     * @return float|string
241
     */
242
    public function subtotal(?int $decimals = null, ?string $decimalPoint = null, ?string $thousandSeperator = null)
243
    {
244 1
        $content = $this->getContent();
245
246 1
        $subTotal = $content->reduce(fn ($subTotal, CartItem $cartItem) => $subTotal + ($cartItem->qty * $cartItem->price), 0);
247
248 1
        return func_num_args() < 2 ? $subTotal : $this->numberFormat($subTotal, $decimals, $decimalPoint, $thousandSeperator);
249
    }
250
251
    /**
252
     * Search the cart content for a cart item matching the given search closure.
253
     */
254
    public function search(Closure $search): Collection
255
    {
256 1
        $content = $this->getContent();
257
258 1
        return $content->filter($search);
259
    }
260
261
    /**
262
     * Set the tax rate for the cart item with the given rowId.
263
     */
264
    public function setTax(string $rowId, float|int $taxRate): void
265
    {
266 1
        $cartItem = $this->get($rowId);
267
268 1
        $cartItem->setTaxRate($taxRate);
269
270 1
        $content = $this->getContent();
271
272 1
        $content->put($cartItem->rowId, $cartItem);
273
274 1
        $this->store->put($content);
275
    }
276
277
    /**
278
     * Magic method to make accessing the total, tax and subtotal properties possible.
279
     *
280
     * @param string $attribute
281
     *
282
     * @return float|null
283
     */
284
    public function __get($attribute)
285
    {
286
        if ($attribute === 'total') {
287 1
            return $this->total();
288
        }
289
290
        if ($attribute === 'tax') {
291 1
            return $this->tax();
292
        }
293
294
        if ($attribute === 'subtotal') {
295 1
            return $this->subtotal();
296
        }
297
298 1
        return null;
299
    }
300
301
    /**
302
     * Get the carts content, if there is no cart content set yet, return a new empty Collection
303
     */
304
    protected function getContent(): Collection
305
    {
306
        $content = $this->store->has()
307
            ? $this->store->get()
308 3
            : new Collection();
309
310 3
        return $content->map(fn ($attribute) => $this->createCartItem($attribute, null, null, null, []));
311
    }
312
313
    /**
314
     * Create a new CartItem from the supplied attributes.
315
     *
316
     * @param array<string, mixed>|float|int|null $qty
317
     * @param array<string, mixed>                $options
318
     */
319
    private function createCartItem(mixed $id, mixed $name, null|array|float|int $qty, ?float $price, array $options): CartItem
320
    {
321 3
        $taxRate = null;
322
323
        if ($id instanceof Buyable) {
324 1
            $cartItem = CartItem::fromBuyable($id, $qty ?: []);
325 1
            $cartItem->setQuantity($name ?: 1);
326
        } elseif (is_array($id)) {
327 3
            $cartItem = CartItem::fromArray($id);
328 3
            $cartItem->setQuantity($id['qty']);
329
            if (isset($id['tax']) && $id['price'] != 0) {
330 3
                $taxRate = (100 * $id['tax']) / $id['price'];
331
            }
332
        } else {
333 3
            $cartItem = CartItem::fromAttributes($id, $name, $price, $options);
334 3
            $cartItem->setQuantity($qty);
335
        }
336
337 3
        $cartItem->setTaxRate($taxRate ?? $this->config('tax'));
338
339 3
        return $cartItem;
340
    }
341
342
    /**
343
     * Check if the item is a multidimensional array or an array of Buyables.
344
     */
345
    private function isMulti(mixed $item): bool
346
    {
347
        if (! is_array($item)) {
348 3
            return false;
349
        }
350
351 1
        $head = reset($item);
352
353 1
        return is_array($head) || $head instanceof Buyable;
354
    }
355
356
    /**
357
     * Get the Formated number
358
     */
359
    private function numberFormat(float $value, ?int $decimals, ?string $decimalPoint, ?string $thousandSeperator): string
360
    {
361
        if (null === $decimals) {
362 1
            $decimals = $this->config('format.decimals', 2);
363
        }
364
        if (null === $decimalPoint) {
365 1
            $decimalPoint = $this->config('format.decimal_point');
366
        }
367
        if (null === $thousandSeperator) {
368 1
            $thousandSeperator = $this->config('format.thousand_seperator');
369
        }
370
371 1
        return number_format($value, $decimals, $decimalPoint, $thousandSeperator);
372
    }
373
374
    /**
375
     * Get a specific configuration for card
376
     */
377
    private function config(string $key, mixed $default = null): mixed
378
    {
379 3
        return Arr::dataGet($this->config, $key, $default);
380
    }
381
382
    /**
383
     * Emit event
384
     */
385
    private function emit(string $event, mixed $target = null): void
386
    {
387
        if (null !== $this->event) {
388 3
            $this->event->trigger($event, $target);
389
        }
390
    }
391
392
    /**
393
     * Initialize cart store manager
394
     */
395
    private function initStore(): self
396
    {
397
        if (null === $this->store) {
398
            /** @var class-string<StoreManager> */
399 3
            $handler = $this->config('handler', Session::class);
400
            if (! class_exists($handler) || ! is_a($handler, StoreManager::class, true)) {
401 3
                throw new InvalidArgumentException(sprintf('handler must be an class that implements %s', StoreManager::class));
402
            }
403
404 3
            $this->store = new $handler();
405
        }
406
407
        if (! $this->store->init($this->currentInstance())) {
408 2
            throw new RuntimeException(sprintf('Handler %s could not be initialize', get_class($this->store)));
409
        }
410
411 3
        return $this;
412
    }
413
}
414