File Coverage

blib/lib/Nitesi/Cart.pm
Criterion Covered Total %
statement 205 222 92.3
branch 56 76 73.6
condition 19 30 63.3
subroutine 26 27 96.3
pod 21 21 100.0
total 327 376 86.9


line stmt bran cond sub pod time code
1             # Nitesi::Cart - Nitesi cart class
2              
3             package Nitesi::Cart;
4              
5 2     2   55293 use strict;
  2         6  
  2         85  
6 2     2   10 use warnings;
  2         4  
  2         67  
7              
8 2     2   12 use constant CART_DEFAULT => 'main';
  2         4  
  2         5396  
9              
10             =head1 NAME
11              
12             Nitesi::Cart - Cart class for Nitesi Shop Machine
13              
14             =head1 DESCRIPTION
15              
16             Generic cart class for L.
17              
18             =head2 CART ITEMS
19              
20             Each item in the cart has at least the following attributes:
21              
22             =over 4
23              
24             =item sku
25              
26             Unique item identifier.
27              
28             =item name
29              
30             Item name.
31              
32             =item quantity
33              
34             Item quantity.
35              
36             =item price
37              
38             Item price.
39              
40             =back
41              
42             =head1 CONSTRUCTOR
43              
44             =head2 new
45              
46             =cut
47              
48             sub new {
49 6     6 1 722 my ($class, $self, %args);
50              
51 6         10 $class = shift;
52 6         15 %args = @_;
53              
54 6         20 my $time = time;
55              
56 6         54 $self = {error => '', items => [], modifiers => [],
57             costs => [], subtotal => 0, total => 0,
58             cache_subtotal => 1, cache_total => 1,
59             created => $time, last_modified => $time,
60             };
61              
62 6 50       21 if ($args{name}) {
63 0         0 $self->{name} = $args{name};
64             }
65             else {
66 6         13 $self->{name} = CART_DEFAULT;
67             }
68              
69 6         13 for my $ts (qw/created last_modified/) {
70 12 100       35 if (exists $args{$ts}) {
71 1         4 $self->{$ts} = $args{$ts};
72             }
73             }
74              
75 6 50       17 if ($args{modifiers}) {
76 0         0 $self->{modifiers} = $args{modifiers};
77             }
78              
79 6 100       18 if ($args{run_hooks}) {
80 2         4 $self->{run_hooks} = $args{run_hooks};
81             }
82              
83 6         15 bless $self, $class;
84              
85 6         21 $self->init(%args);
86              
87 6         19 return $self;
88             }
89              
90             =head2 init
91              
92             Initializer which receives the constructor arguments, but does nothing.
93             May be overridden in a subclass.
94              
95             =cut
96              
97             sub init {
98 6     6 1 8 return 1;
99             };
100              
101             =head1 METHODS
102              
103             =head2 items
104              
105             Returns items in the cart.
106              
107             =cut
108              
109             sub items {
110 7     7 1 235 my ($self) = shift;
111              
112 7         18 return $self->{items};
113             }
114              
115             =head2 subtotal
116              
117             Returns subtotal of the cart.
118              
119             =cut
120              
121             sub subtotal {
122 7     7 1 12 my ($self) = shift;
123              
124 7 100       18 if ($self->{cache_subtotal}) {
125 3         8 return $self->{subtotal};
126             }
127              
128 4         7 $self->{subtotal} = 0;
129              
130 4         6 for my $item (@{$self->{items}}) {
  4         10  
131 6         36 $self->{subtotal} += $item->{price} * $item->{quantity};
132             }
133              
134 4         6 $self->{cache_subtotal} = 1;
135              
136 4         11 return $self->{subtotal};
137             }
138              
139             =head2 total
140              
141             Returns total of the cart.
142              
143             =cut
144              
145             sub total {
146 8     8 1 435 my ($self) = shift;
147 8         10 my ($subtotal);
148              
149 8 100       23 if ($self->{cache_total}) {
150 1         3 return $self->{total};
151             }
152              
153 7         17 $self->{total} = $subtotal = $self->subtotal();
154              
155             # calculate costs
156 7         20 $self->{total} += $self->_calculate($subtotal);
157              
158 7         9 $self->{cache_total} = 1;
159              
160 7         19 return $self->{total};
161             }
162            
163             =head2 add $item
164              
165             Add item to the cart. Returns item in case of success.
166              
167             The item is a hash (reference) which is subject to the following
168             conditions:
169              
170             =over 4
171              
172             =item sku
173              
174             Item identifier is required.
175              
176             =item name
177              
178             Item name is required.
179              
180             =item quantity
181              
182             Item quantity is optional and has to be a natural number greater
183             than zero. Default for quantity is 1.
184              
185             =item price
186              
187             Item price is required and a positive number.
188              
189             Price is required, because you want to maintain the price that was valid at the time of adding to the cart. Should the price in the shop change in the meantime, it will maintain this price. If you would like to update the pages, you have to do it before loading the cart page on your shop.
190              
191              
192             B Add 5 BMX2012 products to the cart
193              
194             $cart->add( sku => 'BMX2012', quantity => 5, price => 200);
195              
196             B Add a BMX2012 product to the cart.
197              
198             $cart->add( sku => 'BMX2012', price => 200);
199              
200             =back
201              
202             =cut
203              
204             sub add {
205 12     12 1 1063 my $self = shift;
206 12         14 my (%item, $ret);
207              
208 12 50       27 if (ref($_[0])) {
209             # copy item
210 12         481 %item = %{$_[0]};
  12         47  
211             }
212             else {
213 0         0 %item = @_;
214             }
215              
216             # run hooks before validating item
217 12         35 $self->_run_hook('before_cart_add_validate', $self, \%item);
218              
219             # validate item
220 12 50 66     104 unless (exists $item{sku} && defined $item{sku} && $item{sku} =~ /\S/) {
      66        
221 1         3 $self->{error} = 'Item added without SKU.';
222 1         2 return;
223             }
224              
225 11 50 66     97 unless (exists $item{name} && defined $item{name} && $item{name} =~ /\S/) {
      66        
226 1         4 $self->{error} = "Item $item{sku} added without a name.";
227 1         4 return;
228             }
229              
230 10 100 66     74 if (exists $item{quantity} && defined $item{quantity}) {
231 3 50 33     24 unless ($item{quantity} =~ /^(\d+)$/ && $item{quantity} > 0) {
232 0         0 $self->{error} = "Item $item{sku} added with invalid quantity $item{quantity}.";
233 0         0 return;
234             }
235             }
236             else {
237 7         15 $item{quantity} = 1;
238             }
239              
240 10 50 66     153 unless (exists $item{price} && defined $item{price}
      66        
      33        
241             && $item{price} =~ /^(\d+)(\.\d+)?$/ && $item{price} > 0) {
242 1         4 $self->{error} = "Item $item{sku} added with invalid price.";
243 1         4 return;
244             }
245            
246             # run hooks before adding item to cart
247 9         622 $self->_run_hook('before_cart_add', $self, \%item);
248              
249 9 100       23 if (exists $item{error}) {
250             # one of the hooks denied the item
251 1         3 $self->{error} = $item{error};
252 1         5 return;
253             }
254              
255             # clear cache flags
256 8         17 $self->{cache_subtotal} = $self->{cache_total} = 0;
257              
258 8 100       20 unless ($ret = $self->_combine(\%item)) {
259 7         8 push @{$self->{items}}, \%item;
  7         14  
260 7         14 $self->{last_modified} = time;
261             }
262              
263             # run hooks after adding item to cart
264 8         18 $self->_run_hook('after_cart_add', $self, \%item, $ret);
265              
266 8         25 return \%item;
267             }
268              
269             =head2 remove $sku
270              
271             Remove item from the cart. Takes SKU of item to identify the item.
272              
273             =cut
274              
275             sub remove {
276 3     3 1 261 my ($self, $arg) = @_;
277 3         4 my ($pos, $found, $item);
278              
279 3         4 $pos = 0;
280            
281             # run hooks before locating item
282 3         5 $self->_run_hook('before_cart_remove_validate', $self, $arg);
283              
284 3         3 for $item (@{$self->{items}}) {
  3         7  
285 4 100       10 if ($item->{sku} eq $arg) {
286 3         4 $found = 1;
287 3         6 last;
288             }
289 1         2 $pos++;
290             }
291              
292 3 50       7 if ($found) {
293             # run hooks before adding item to cart
294 3         5 $item = $self->{items}->[$pos];
295              
296 3         7 $self->_run_hook('before_cart_remove', $self, $item);
297              
298 3 100       7 if (exists $item->{error}) {
299             # one of the hooks denied removing the item
300 1         3 $self->{error} = $item->{error};
301 1         3 return;
302             }
303              
304             # clear cache flags
305 2         3 $self->{cache_subtotal} = $self->{cache_total} = 0;
306              
307             # removing item from our array
308 2         2 splice(@{$self->{items}}, $pos, 1);
  2         6  
309              
310 2         4 $self->{last_modified} = time;
311              
312 2         4 $self->_run_hook('after_cart_remove', $self, $item);
313 2         6 return 1;
314             }
315              
316             # item missing
317 0         0 $self->{error} = "Missing item $arg.";
318              
319 0         0 return;
320             }
321              
322             =head2 update
323              
324             Update quantity of items in the cart.
325              
326             Parameters are pairs of SKUs and quantities, e.g.
327              
328             $cart->update(9780977920174 => 5,
329             9780596004927 => 3);
330              
331             Triggers before_cart_update and after_cart_update hooks.
332              
333             A quantity of zero is equivalent to removing this item,
334             so in this case the remove hooks will be invoked instead
335             of the update hooks.
336              
337             =cut
338              
339             sub update {
340 3     3 1 796 my ($self, @args) = @_;
341 3         5 my ($ref, $sku, $qty, $item, $new_item);
342              
343 3         9 while (@args > 0) {
344 4         6 $sku = shift @args;
345 4         6 $qty = shift @args;
346              
347 4 50       11 unless ($item = $self->find($sku)) {
348 0         0 die "Item for $sku not found in cart.\n";
349             }
350              
351 4 100       10 if ($qty == 0) {
352             # remove item instead
353 1         3 $self->remove($sku);
354 1         6 next;
355             }
356              
357             # jump to next item if quantity stays the same
358 3 100       12 next if $qty == $item->{quantity};
359              
360             # run hook before updating the cart
361 2         6 $new_item = {quantity => $qty};
362              
363 2         5 $self->_run_hook('before_cart_update', $self, $item, $new_item);
364              
365 2 50       7 if (exists $new_item->{error}) {
366             # one of the hooks denied the item
367 0         0 $self->{error} = $new_item->{error};
368 0         0 return;
369             }
370              
371 2         3 $self->{last_modified} = time;
372              
373 2         5 $self->_run_hook('after_cart_update', $self, $item, $new_item);
374              
375 2         8 $item->{quantity} = $qty;
376             }
377             }
378              
379             =head2 clear
380              
381             Removes all items from the cart.
382              
383             =cut
384              
385             sub clear {
386 2     2 1 483 my ($self) = @_;
387              
388             # run hook before clearing the cart
389 2         6 $self->_run_hook('before_cart_clear', $self);
390            
391 2         3 $self->{items} = [];
392              
393             # run hook after clearing the cart
394 2         9 $self->_run_hook('after_cart_clear', $self);
395              
396             # reset subtotal/total
397 2         4 $self->{subtotal} = 0;
398 2         3 $self->{total} = 0;
399 2         3 $self->{cache_subtotal} = 1;
400 2         2 $self->{cache_total} = 1;
401              
402 2         4 $self->{last_modified} = time;
403              
404 2         5 return;
405             }
406              
407             =head2 find
408              
409             Searches for an cart item with the given SKU.
410             Returns cart item in case of sucess.
411              
412             if ($item = $cart->find(9780977920174)) {
413             print "Quantity: $item->{quantity}.\n";
414             }
415              
416             =cut
417              
418             sub find {
419 4     4 1 5 my ($self, $sku) = @_;
420              
421 4         4 for my $cartitem (@{$self->{items}}) {
  4         10  
422 5 100       12 if ($sku eq $cartitem->{sku}) {
423 4         13 return $cartitem;
424             }
425             }
426              
427 0         0 return;
428             }
429              
430             =head2 quantity
431              
432             Returns the sum of the quantity of all items in the shopping cart,
433             which is commonly used as number of items. If you have 5 apples and 6 pears it will return 11.
434              
435             print 'Items in your cart: ', $cart->quantity, "\n";
436              
437             =cut
438              
439             sub quantity {
440 5     5 1 1253 my $self = shift;
441 5         7 my $qty = 0;
442              
443 5         7 for my $item (@{$self->{items}}) {
  5         12  
444 7         17 $qty += $item->{quantity};
445             }
446              
447 5         12 return $qty;
448             }
449              
450             =head2 created
451              
452             Returns the time (epoch) when the cart was created.
453              
454             =cut
455              
456             sub created {
457 1     1 1 233 my ($self) = @_;
458              
459 1         3 return $self->{created};
460             }
461              
462             =head2 last_modified
463              
464             Returns the time (epoch) when the cart was last modified.
465              
466             =cut
467              
468             sub last_modified {
469 5     5 1 937 my ($self) = @_;
470              
471 5         18 return $self->{last_modified};
472             }
473              
474             =head2 count
475              
476             Returns the number of different items in the shopping cart. If you have 5 apples and 6 pears it will return 2 (2 different items).
477              
478             =cut
479              
480             sub count {
481 5     5 1 283 my $self = shift;
482              
483 5         7 return scalar(@{$self->{items}});
  5         18  
484             }
485              
486             =head2 apply_cost
487              
488             Apply cost to cart. apply_cost is a generic method typicaly used for taxes, discounts, coupons, gift certificates,...
489              
490             B Absolute cost
491              
492             Uses absolute value for amount. Amount 5 is 5 units of currency used (ie. $5).
493              
494             $cart->apply_cost(amount => 5, name => 'shipping', label => 'Shipping');
495              
496             B Relative cost
497              
498             Uses percentage instead of value for amount. Amount 0.19 in example is 19%.
499              
500             relative is a boolean value (0/1).
501              
502             $cart->apply_cost(amount => 0.19, name => 'tax', label => 'VAT', relative => 1);
503              
504             B Inclusive cost
505              
506             Same as relative cost, but it assumes that tax was included in the subtotal already, and only displays it (19% of subtotal value in example). Inclusive is a boolean value (0/1).
507              
508             $cart->apply_cost(amount => 0.19, name => 'tax', label => 'Sales Tax', relative => 1, inclusive => 1);
509              
510             =cut
511              
512             sub apply_cost {
513 4     4 1 29 my ($self, %args) = @_;
514              
515 4         5 push @{$self->{costs}}, \%args;
  4         16  
516              
517 4 100       15 unless ($args{inclusive}) {
518             # clear cache for total
519 3         11 $self->{cache_total} = 0;
520             }
521             }
522              
523             =head2 clear_cost
524              
525             It removes all the costs previously applied (using apply_cost). Used typically if you have free shipping or something similar, you can clear the costs.
526              
527             =cut
528              
529             sub clear_cost {
530 4     4 1 1090 my $self = shift;
531              
532 4         9 $self->{costs} = [];
533              
534 4         136 $self->{cache_total} = 0;
535             }
536              
537             =head2 cost
538              
539             Returns particular cost by position or by name.
540              
541             B Return tax value by name
542            
543             $cart->cost('tax');
544              
545             Returns value of the tax (absolute value in your currency, not percantage)
546              
547             B Return tax value by position
548              
549             $cart->cost(0);
550              
551             Returns the cost that was first applied to subtotal. By increasing the number you can retrieve other costs applied.
552              
553             =cut
554              
555             sub cost {
556 6     6 1 1644 my ($self, $loc) = @_;
557 6         8 my ($cost, $ret);
558              
559 6 50       13 if (defined $loc) {
560 6 100       26 if ($loc =~ /^\d+/) {
    50          
561             # cost by position
562 3         7 $cost = $self->{costs}->[$loc];
563             }
564             elsif ($loc =~ /\S/) {
565             # cost by name
566 3         3 for my $c (@{$self->{costs}}) {
  3         7  
567 3 50       8 if ($c->{name} eq $loc) {
568 3         7 $cost = $c;
569             }
570             }
571             }
572             }
573              
574 6 50       13 if (defined $cost) {
575 6         14 $ret = $self->_calculate($self->{subtotal}, $cost, 1);
576             }
577              
578 6         26 return $ret;
579             }
580              
581             =head2 id
582              
583             Get or set id of the cart. This can be used for subclasses,
584             e.g. primary key value for carts in the database.
585              
586             =cut
587              
588             sub id {
589 0     0 1 0 my $self = shift;
590              
591 0 0       0 if (@_ > 0) {
592 0         0 $self->{id} = $_[0];
593             }
594              
595 0         0 return $self->{id};
596             }
597              
598             =head2 name
599              
600             Get or set the name of the cart.
601              
602             =cut
603              
604             sub name {
605 2     2 1 411 my $self = shift;
606              
607 2 100       7 if (@_ > 0) {
608 1         2 my $old_name = $self->{name};
609              
610 1         6 $self->_run_hook('before_cart_rename', $self, $old_name, $_[0]);
611              
612 1         1 $self->{name} = $_[0];
613 1         2 $self->{last_modified} = time;
614              
615 1         4 $self->_run_hook('after_cart_rename', $self, $old_name, $_[0]);
616             }
617              
618 2         10 return $self->{name};
619             }
620              
621             =head2 error
622              
623             Returns last error.
624              
625             =cut
626              
627             sub error {
628 10     10 1 342 my $self = shift;
629              
630 10         40 return $self->{error};
631             }
632              
633             =head2 seed $item_ref
634              
635             Seeds items within the cart from $item_ref.
636              
637             B
638              
639             $cart->seed([
640             { sku => 'BMX2015', price => 20, quantity = 1 },
641             { sku => 'KTM2018', price => 400, quantity = 5 },
642             { sku => 'DBF2020', price => 200, quantity = 5 },
643             ]);
644              
645             =cut
646              
647             sub seed {
648 1     1 1 63 my ($self, $item_ref) = @_;
649              
650 1 50       3 @{$self->{items}} = @{$item_ref || []};
  1         4  
  1         7  
651              
652             # clear cache flags
653 1         3 $self->{cache_subtotal} = $self->{cache_total} = 0;
654              
655 1         2 $self->{last_modified} = time;
656              
657 1         4 return $self->{items};
658             }
659              
660             sub _combine {
661 8     8   11 my ($self, $item) = @_;
662              
663 8         9 ITEMS: for my $cartitem (@{$self->{items}}) {
  8         19  
664 4 100       15 if ($item->{sku} eq $cartitem->{sku}) {
665 1         2 for my $mod (@{$self->{modifiers}}) {
  1         2  
666 0 0       0 next ITEMS unless($item->{$mod} eq $cartitem->{$mod});
667             }
668            
669 1         3 $cartitem->{'quantity'} += $item->{'quantity'};
670 1         2 $item->{'quantity'} = $cartitem->{'quantity'};
671              
672 1         4 return 1;
673             }
674             }
675              
676 7         22 return 0;
677             }
678              
679             sub _calculate {
680 13     13   20 my ($self, $subtotal, $costs, $display) = @_;
681 13         14 my ($cost_ref, $sum);
682              
683 13 100       37 if (ref $costs eq 'HASH') {
    50          
684 6         8 $cost_ref = [$costs];
685             }
686             elsif (ref $costs eq 'ARRAY') {
687 0         0 $cost_ref = $costs;
688             }
689             else {
690 7         13 $cost_ref = $self->{costs};
691             }
692              
693 13         26 $sum = 0;
694              
695 13         21 for my $calc (@$cost_ref) {
696 10 100 100     30 if ($calc->{inclusive} && ! $display) {
697 1         3 next;
698             }
699              
700 9 100       16 if ($calc->{relative}) {
701 6         20 $sum += $subtotal * $calc->{amount};
702             }
703             else {
704 3         8 $sum += $calc->{amount};
705             }
706             }
707              
708 13         38 return $sum;
709             }
710              
711             sub _run_hook {
712 47     47   89 my ($self, $name, @args) = @_;
713 47         45 my $ret;
714              
715 47 100       108 if ($self->{run_hooks}) {
716 21         44 $ret = $self->{run_hooks}->($name, @args);
717             }
718              
719 47         168 return $ret;
720             }
721              
722             =head1 AUTHOR
723              
724             Stefan Hornburg (Racke),
725              
726             =head1 LICENSE AND COPYRIGHT
727              
728             Copyright 2011-2013 Stefan Hornburg (Racke) .
729              
730             This program is free software; you can redistribute it and/or modify it
731             under the terms of either: the GNU General Public License as published
732             by the Free Software Foundation; or the Artistic License.
733              
734             See http://dev.perl.org/licenses/ for more information.
735              
736             =cut
737              
738             1;