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
|
|||
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 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 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 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 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 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 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 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 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 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 |
This check looks for function or method calls that always return null and whose return value is used.
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.