Completed
Push — master ( 3c661d...2a57f9 )
by Will
26s queued 12s
created

src/Cart/ShoppingCart.php (10 issues)

1
<?php
2
3
namespace SilverShop\Cart;
4
5
use Exception;
6
use SilverShop\Extension\OrderManipulationExtension;
7
use SilverShop\Extension\ProductVariationsExtension;
8
use SilverShop\Model\Buyable;
9
use SilverShop\Model\Order;
10
use SilverShop\Model\OrderItem;
11
use SilverShop\ORM\Filters\MatchObjectFilter;
12
use SilverShop\Page\Product;
13
use SilverShop\ShopTools;
14
use SilverStripe\Core\Config\Config;
15
use SilverStripe\Core\Config\Configurable;
16
use SilverStripe\Core\Injector\Injectable;
17
use SilverStripe\Security\Member;
18
use SilverStripe\Security\Security;
19
20
/**
21
 * Encapsulated manipulation of the current order using a singleton pattern.
22
 *
23
 * Ensures that an order is only started (persisted to DB) when necessary,
24
 * and all future changes are on the same order, until the order has is placed.
25
 * The requirement for starting an order is to adding an item to the cart.
26
 *
27
 * @package shop
28
 */
29
class ShoppingCart
30
{
31
    use Injectable;
32
    use Configurable;
33
34
    private static $cartid_session_name = 'SilverShop.shoppingcartid';
35
36
    /**
37
     * @var Order
38
     */
39
    private $order;
40
41
    private $calculateonce = false;
42
43
    private $message;
44
45
    private $type;
46
47
48
    /**
49
     * Shortened alias for ShoppingCart::singleton()->current()
50
     *
51
     * @return Order
52
     */
53
    public static function curr()
54
    {
55
        return self::singleton()->current();
56
    }
57
58
    /**
59
     * Get the current order, or return null if it doesn't exist.
60
     *
61
     * @return Order
62
     */
63
    public function current()
64
    {
65
        $session = ShopTools::getSession();
66
        //find order by id saved to session (allows logging out and retaining cart contents)
67
        if (!$this->order && $sessionid = $session->get(self::config()->cartid_session_name)) {
68
            $this->order = Order::get()->filter(
69
                [
70
                    'Status' => 'Cart',
71
                    'ID' => $sessionid,
72
                ]
73
            )->first();
74
        }
75
        if (!$this->calculateonce && $this->order) {
76
            $this->order->calculate();
77
            $this->calculateonce = true;
78
        }
79
80
        return $this->order ? $this->order : null;
81
    }
82
83
    /**
84
     * Set the current cart
85
     *
86
     * @param Order $cart the Order to use as the current cart-content
87
     *
88
     * @return ShoppingCart
89
     */
90
    public function setCurrent(Order $cart)
91
    {
92
        if (!$cart->IsCart()) {
93
            trigger_error('Passed Order object is not cart status', E_ERROR);
94
        }
95
        $this->order = $cart;
96
        $session = ShopTools::getSession();
97
        $session->set(self::config()->cartid_session_name, $cart->ID);
98
99
        return $this;
100
    }
101
102
    /**
103
     * Helper that only allows orders to be started internally.
104
     *
105
     * @return Order
106
     */
107
    protected function findOrMake()
108
    {
109
        if ($this->current()) {
110
            return $this->current();
111
        }
112
        $this->order = Order::create();
113
        if (Member::config()->login_joins_cart && ($member = Security::getCurrentUser())) {
114
            $this->order->MemberID = $member->ID;
115
        }
116
        $this->order->write();
117
        $this->order->extend('onStartOrder');
118
119
        $session = ShopTools::getSession();
120
        $session->set(self::config()->cartid_session_name, $this->order->ID);
121
122
        return $this->order;
123
    }
124
125
    /**
126
     * Adds an item to the cart
127
     *
128
     * @param Buyable $buyable
129
     * @param int     $quantity
130
     * @param array   $filter
131
     *
132
     * @return boolean|OrderItem false or the new/existing item
133
     */
134
    public function add(Buyable $buyable, $quantity = 1, $filter = [])
135
    {
136
        $order = $this->findOrMake();
137
138
        // If an extension throws an exception, error out
139
        try {
140
            $order->extend('beforeAdd', $buyable, $quantity, $filter);
141
        } catch (Exception $exception) {
142
            return $this->error($exception->getMessage());
0 ignored issues
show
Are you sure the usage of $this->error($exception->getMessage()) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
143
        }
144
145
        if (!$buyable) {
146
            return $this->error(_t(__CLASS__ . '.ProductNotFound', 'Product not found.'));
0 ignored issues
show
Are you sure the usage of $this->error(_t(__CLASS_... 'Product not found.')) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
147
        }
148
149
        $item = $this->findOrMakeItem($buyable, $quantity, $filter);
150
        if (!$item) {
151
            return false;
152
        }
153
        if (!$item->_brandnew) {
154
            $item->Quantity += $quantity;
155
        } else {
156
            $item->Quantity = $quantity;
157
        }
158
159
        // If an extension throws an exception, error out
160
        try {
161
            $order->extend('afterAdd', $item, $buyable, $quantity, $filter);
162
        } catch (Exception $exception) {
163
            return $this->error($exception->getMessage());
0 ignored issues
show
Are you sure the usage of $this->error($exception->getMessage()) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
164
        }
165
166
        $item->write();
167
        $this->message(_t(__CLASS__ . '.ItemAdded', 'Item has been added successfully.'));
168
169
        return $item;
170
    }
171
172
    /**
173
     * Remove an item from the cart.
174
     *
175
     * @param Buyable $buyable
176
     * @param int     $quantity - number of items to remove, or leave null for all items (default)
177
     * @param array   $filter
178
     *
179
     * @return boolean success/failure
180
     */
181
    public function remove(Buyable $buyable, $quantity = null, $filter = [])
182
    {
183
        $order = $this->current();
184
185
        if (!$order) {
186
            return $this->error(_t(__CLASS__ . '.NoOrder', 'No current order.'));
187
        }
188
189
        // If an extension throws an exception, error out
190
        try {
191
            $order->extend('beforeRemove', $buyable, $quantity, $filter);
192
        } catch (Exception $exception) {
193
            return $this->error($exception->getMessage());
0 ignored issues
show
Are you sure the usage of $this->error($exception->getMessage()) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
194
        }
195
196
        $item = $this->get($buyable, $filter);
197
198
        if (!$item || !$this->removeOrderItem($item, $quantity)) {
199
            return false;
200
        }
201
202
        // If an extension throws an exception, error out
203
        // TODO: There should be a rollback
204
        try {
205
            $order->extend('afterRemove', $item, $buyable, $quantity, $filter);
206
        } catch (Exception $exception) {
207
            return $this->error($exception->getMessage());
0 ignored issues
show
Are you sure the usage of $this->error($exception->getMessage()) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
208
        }
209
210
        $this->message(_t(__CLASS__ . '.ItemRemoved', 'Item has been successfully removed.'));
211
212
        return true;
213
    }
214
215
    /**
216
     * Remove a specific order item from cart
217
     *
218
     * @param  OrderItem $item
219
     * @param  int       $quantity - number of items to remove or leave `null` to remove all items (default)
220
     * @return boolean success/failure
221
     */
222
    public function removeOrderItem(OrderItem $item, $quantity = null)
223
    {
224
        $order = $this->current();
225
226
        if (!$order) {
227
            return $this->error(_t(__CLASS__ . '.NoOrder', 'No current order.'));
228
        }
229
230
        if (!$item || $item->OrderID != $order->ID) {
231
            return $this->error(_t(__CLASS__ . '.ItemNotFound', 'Item not found.'));
0 ignored issues
show
Are you sure the usage of $this->error(_t(__CLASS_...d', 'Item not found.')) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
232
        }
233
234
        //if $quantity will become 0, then remove all
235
        if (!$quantity || ($item->Quantity - $quantity) <= 0) {
236
            $item->delete();
237
            $item->destroy();
238
        } else {
239
            $item->Quantity -= $quantity;
240
            $item->write();
241
        }
242
243
        return true;
244
    }
245
246
    /**
247
     * Sets the quantity of an item in the cart.
248
     * Will automatically add or remove item, if necessary.
249
     *
250
     * @param Buyable $buyable
251
     * @param int     $quantity
252
     * @param array   $filter
253
     *
254
     * @return boolean|OrderItem false or the new/existing item
255
     */
256
    public function setQuantity(Buyable $buyable, $quantity = 1, $filter = [])
257
    {
258
        if ($quantity <= 0) {
259
            return $this->remove($buyable, $quantity, $filter);
260
        }
261
262
        $item = $this->findOrMakeItem($buyable, $quantity, $filter);
263
264
        if (!$item || !$this->updateOrderItemQuantity($item, $quantity, $filter)) {
265
            return false;
266
        }
267
268
        return $item;
269
    }
270
271
    /**
272
     * Update quantity of a given order item
273
     *
274
     * @param  OrderItem $item
275
     * @param  int       $quantity the new quantity to use
276
     * @param  array     $filter
277
     * @return boolean success/failure
278
     */
279
    public function updateOrderItemQuantity(OrderItem $item, $quantity = 1, $filter = [])
280
    {
281
        $order = $this->current();
282
283
        if (!$order) {
284
            return $this->error(_t(__CLASS__ . '.NoOrder', 'No current order.'));
285
        }
286
287
        if (!$item || $item->OrderID != $order->ID) {
288
            return $this->error(_t(__CLASS__ . '.ItemNotFound', 'Item not found.'));
0 ignored issues
show
Are you sure the usage of $this->error(_t(__CLASS_...d', 'Item not found.')) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
289
        }
290
291
        $buyable = $item->Buyable();
292
        // If an extension throws an exception, error out
293
        try {
294
            $order->extend('beforeSetQuantity', $buyable, $quantity, $filter);
295
        } catch (Exception $exception) {
296
            return $this->error($exception->getMessage());
0 ignored issues
show
Are you sure the usage of $this->error($exception->getMessage()) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
297
        }
298
299
        $item->Quantity = $quantity;
300
301
        // If an extension throws an exception, error out
302
        try {
303
            $order->extend('afterSetQuantity', $item, $buyable, $quantity, $filter);
304
        } catch (Exception $exception) {
305
            return $this->error($exception->getMessage());
0 ignored issues
show
Are you sure the usage of $this->error($exception->getMessage()) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
306
        }
307
308
        $item->write();
309
        $this->message(_t(__CLASS__ . '.QuantitySet', 'Quantity has been set.'));
310
311
        return true;
312
    }
313
314
    /**
315
     * Finds or makes an order item for a given product + filter.
316
     *
317
     * @param Buyable $buyable  the buyable
318
     * @param int     $quantity quantity to add
319
     * @param array   $filter
320
     *
321
     * @return OrderItem the found or created item
322
     * @throws \SilverStripe\ORM\ValidationException
323
     */
324
    private function findOrMakeItem(Buyable $buyable, $quantity = 1, $filter = [])
325
    {
326
        $order = $this->findOrMake();
327
328
        if (!$buyable || !$order) {
329
            return null;
330
        }
331
332
        $item = $this->get($buyable, $filter);
333
334
        if (!$item) {
335
            $member = Security::getCurrentUser();
336
337
            $buyable = $this->getCorrectBuyable($buyable);
338
339
            if (!$buyable->canPurchase($member, $quantity)) {
340
                return $this->error(
341
                    _t(
342
                        __CLASS__ . '.CannotPurchase',
343
                        'This {Title} cannot be purchased.',
344
                        '',
345
                        ['Title' => $buyable->i18n_singular_name()]
346
                    )
347
                );
348
                //TODO: produce a more specific message
349
            }
350
351
            $item = $buyable->createItem($quantity, $filter);
352
            $item->OrderID = $order->ID;
353
            $item->write();
354
355
            $order->Items()->add($item);
356
357
            $item->_brandnew = true; // flag as being new
358
        }
359
360
        return $item;
361
    }
362
363
    /**
364
     * Finds an existing order item.
365
     *
366
     * @param Buyable $buyable
367
     * @param array   $customfilter
368
     *
369
     * @return OrderItem the item requested or null
370
     */
371
    public function get(Buyable $buyable, $customfilter = array())
372
    {
373
        $order = $this->current();
374
        if (!$buyable || !$order) {
375
            return null;
376
        }
377
378
        $buyable = $this->getCorrectBuyable($buyable);
379
380
        $filter = array(
381
            'OrderID' => $order->ID,
382
        );
383
384
        $itemclass = Config::inst()->get(get_class($buyable), 'order_item');
385
        $relationship = Config::inst()->get($itemclass, 'buyable_relationship');
386
        $filter[$relationship . 'ID'] = $buyable->ID;
387
        $required = ['OrderID', $relationship . 'ID'];
388
        if (is_array($itemclass::config()->required_fields)) {
389
            $required = array_merge($required, $itemclass::config()->required_fields);
390
        }
391
        $query = new MatchObjectFilter($itemclass, array_merge($customfilter, $filter), $required);
392
        $item = $itemclass::get()->where($query->getFilter())->first();
393
        if (!$item) {
394
            return $this->error(_t(__CLASS__ . '.ItemNotFound', 'Item not found.'));
0 ignored issues
show
Are you sure the usage of $this->error(_t(__CLASS_...d', 'Item not found.')) targeting SilverShop\Cart\ShoppingCart::error() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
395
        }
396
397
        return $item;
398
    }
399
400
    /**
401
     * Ensure the proper buyable will be returned for a given buyable…
402
     * This is being used to ensure a product with variations cannot be added to the cart…
403
     * a Variation has to be added instead!
404
     *
405
     * @param  Buyable $buyable
406
     * @return Buyable
407
     */
408
    public function getCorrectBuyable(Buyable $buyable)
409
    {
410
        if ($buyable instanceof Product
411
            && $buyable->hasExtension(ProductVariationsExtension::class)
412
            && $buyable->Variations()->count() > 0
413
        ) {
414
            foreach ($buyable->Variations() as $variation) {
415
                if ($variation->canPurchase()) {
416
                    return $variation;
417
                }
418
            }
419
        }
420
421
        return $buyable;
422
    }
423
424
    /**
425
     * Store old cart id in session order history
426
     *
427
     * @param int|null $requestedOrderId optional parameter that denotes the order that was requested
428
     */
429
    public function archiveorderid($requestedOrderId = null)
430
    {
431
        $session = ShopTools::getSession();
432
        $sessionId = $session->get(self::config()->cartid_session_name);
433
        $order = Order::get()
434
            ->filter('Status:not', 'Cart')
435
            ->byId($sessionId);
436
437
        if ($order && !$order->IsCart()) {
438
            OrderManipulationExtension::add_session_order($order);
439
        }
440
        // in case there was no order requested
441
        // OR there was an order requested AND it's the same one as currently in the session,
442
        // then clear the cart. This check is here to prevent clearing of the cart if the user just
443
        // wants to view an old order (via AccountPage).
444
        if (!$requestedOrderId || ($sessionId == $requestedOrderId)) {
445
            $this->clear();
446
        }
447
    }
448
449
    /**
450
     * Empty / abandon the entire cart.
451
     *
452
     * @param  bool $write whether or not to write the abandoned order
453
     * @return bool - true if successful, false if no cart found
454
     */
455
    public function clear($write = true)
456
    {
457
        $session = ShopTools::getSession();
458
        $session->set(self::config()->cartid_session_name, null)->clear(self::config()->cartid_session_name);
459
        $order = $this->current();
460
        $this->order = null;
461
462
        if ($write) {
463
            if (!$order) {
464
                return $this->error(_t(__CLASS__ . '.NoCartFound', 'No cart found.'));
465
            }
466
            $order->write();
467
        }
468
        $this->message(_t(__CLASS__ . '.Cleared', 'Cart was successfully cleared.'));
469
470
        return true;
471
    }
472
473
    /**
474
     * Store a new error.
475
     */
476
    protected function error($message)
477
    {
478
        $this->message($message, 'bad');
479
480
        return null;
481
    }
482
483
    /**
484
     * Store a message to be fed back to user.
485
     *
486
     * @param string $message
487
     * @param string $type    - good, bad, warning
488
     */
489
    protected function message($message, $type = 'good')
490
    {
491
        $this->message = $message;
492
        $this->type = $type;
493
    }
494
495
    public function getMessage()
496
    {
497
        return $this->message;
498
    }
499
500
    public function getMessageType()
501
    {
502
        return $this->type;
503
    }
504
505
    public function clearMessage()
506
    {
507
        $this->message = null;
508
    }
509
510
    //singleton protection
511
    public function __clone()
512
    {
513
        trigger_error('Clone is not allowed.', E_USER_ERROR);
514
    }
515
516
    public function __wakeup()
517
    {
518
        trigger_error('Unserializing is not allowed.', E_USER_ERROR);
519
    }
520
}
521