File Coverage

blib/lib/Interchange6/Cart.pm
Criterion Covered Total %
statement 148 148 100.0
branch 71 72 100.0
condition 6 6 100.0
subroutine 27 27 100.0
pod 6 6 100.0
total 258 259 100.0


line stmt bran cond sub pod time code
1             package Interchange6::Cart;
2              
3             =head1 NAME
4              
5             Interchange6::Cart - Cart class for Interchange6 Shop Machine
6              
7             =cut
8              
9 4     4   67307 use Carp;
  4         6  
  4         278  
10 4     4   1156 use Interchange6::Types -types;
  4         11  
  4         64  
11 4     4   21382 use Module::Runtime 'use_module';
  4         5376  
  4         22  
12 4     4   1963 use Safe::Isa;
  4         1501  
  4         471  
13              
14 4     4   1994 use Moo;
  4         29351  
  4         24  
15 4     4   7312 use MooX::HandlesVia;
  4         29296  
  4         24  
16 4     4   2336 use MooseX::CoverableModifiers;
  4         20754  
  4         23  
17             with 'Interchange6::Role::Costs';
18 4     4   2340 use namespace::clean;
  4         36612  
  4         13  
19              
20             =head1 DESCRIPTION
21              
22             Generic cart class for L.
23              
24             =head1 SYNOPSIS
25              
26             my $cart = Interchange6::Cart->new();
27              
28             $cart->add( sku => 'ABC', name => 'Foo', price => 23.45 );
29              
30             $cart->update( sku => 'ABC', quantity => 3 );
31              
32             my $product = Interchange::Cart::Product->new( ... );
33              
34             $cart->add($product);
35              
36             $cart->apply_cost( ... );
37              
38             my $total = $cart->total;
39              
40             =head1 ATTRIBUTES
41              
42             See also L.
43              
44             =head2 id
45              
46             Cart id can be used for subclasses, e.g. primary key value for carts in the database.
47              
48             =over
49              
50             =item Writer: C
51              
52             =back
53              
54             =cut
55              
56             has id => (
57             is => 'ro',
58             isa => Str,
59             writer => 'set_id',
60             );
61              
62             =head2 name
63              
64             The cart name. Default is 'main'.
65              
66             =over
67              
68             =item Writer: C
69              
70             =back
71              
72             =cut
73              
74             has name => (
75             is => 'ro',
76             isa => NonEmptyStr,
77             default => 'main',
78             writer => 'rename',
79             );
80              
81             =head2 products
82              
83             Called without args returns a hash reference of L.
84              
85             Anything passed in as a value on object instantiation is ignored. To load
86             products into a cart the preferred methods are L and L which
87             make sure appropriate arguements are passed.
88              
89             =cut
90              
91             has products => (
92             # rwp allows us to clear out products in seed via _set_products
93             # without disturbing what subclasses might expect of clear
94             is => 'rwp',
95             isa => ArrayRef [ CartProduct ],
96             default => sub { [] },
97             handles_via => 'Array',
98             handles => {
99             clear => 'clear',
100             count => 'count',
101             is_empty => 'is_empty',
102             product_first => 'first',
103             product_get => 'get',
104             product_grep => 'grep',
105             product_index => 'first_index',
106             products_array => 'elements',
107             product_delete => 'delete',
108             product_push => 'push',
109             product_set => 'set',
110             },
111             init_arg => undef,
112             );
113              
114             =head2 product_class
115              
116             To allow use of a subclassed L. Defaults to
117             C.
118              
119             =cut
120              
121             has product_class => (
122             is => 'ro',
123             isa => Str,
124             default => 'Interchange6::Cart::Product',
125             );
126              
127             =head2 sessions_id
128              
129             The session ID for the cart.
130              
131             =over
132              
133             =item Writer: C
134              
135             =back
136              
137             =cut
138              
139             has sessions_id => (
140             is => 'ro',
141             isa => Str,
142             clearer => 1,
143             writer => 'set_sessions_id',
144             );
145              
146             =head2 subtotal
147              
148             Returns current cart subtotal excluding costs.
149              
150             =cut
151              
152             has subtotal => (
153             is => 'lazy',
154             clearer => 1,
155             predicate => 1,
156             );
157              
158             sub _build_subtotal {
159 25     25   654 my $self = shift;
160              
161 25         26 my $subtotal = 0;
162 25         314 foreach my $product ( $self->products_array ) {
163 40         1409 $subtotal += $product->total;
164             }
165              
166 25         662 return sprintf( "%.2f", $subtotal );
167             }
168              
169             after 'clear', 'product_push', 'product_set', 'product_delete' => sub {
170 27     27   5855 my $self = shift;
171 27         393 $self->clear_subtotal;
172 27         1814 $self->clear_weight;
173             };
174              
175             after 'clear_subtotal' => sub {
176 70     70   2161 shift->clear_total;
177             };
178              
179             =head2 users_id
180              
181             The user id of the logged in user.
182              
183             =over
184              
185             =item Writer: C
186              
187             =back
188              
189             =cut
190              
191             has users_id => (
192             is => 'ro',
193             isa => Str,
194             writer => 'set_users_id',
195             );
196              
197             =head2 weight
198              
199             Returns total weight of all products in the cart. If all products have
200             unedfined weight then this returns undef.
201              
202             =cut
203              
204             has weight => (
205             is => 'lazy',
206             clearer => 1,
207             predicate => 1,
208             );
209              
210             sub _build_weight {
211 17     17   4633 my $self = shift;
212            
213 17         18 my $weight = 0;
214 17         223 foreach my $product ( grep { defined $_->weight } $self->products_array ) {
  25         549  
215 25         44 $weight += $product->weight * $product->quantity;
216             }
217              
218 17         177 return $weight;
219             }
220              
221             =head1 METHODS
222              
223             See also L.
224              
225             =head2 clear
226              
227             Removes all products from the cart.
228              
229             =head2 count
230              
231             Returns the number of different products in the shopping cart. If you have 5 apples and 6 pears it will return 2 (2 different products).
232              
233             =head2 is_empty
234              
235             Return boolean 1 or 0 depending on whether the cart is empty or not.
236              
237             =head2 product_delete($index)
238              
239             Deletes the product at the specified index.
240              
241             =head2 product_get($index)
242              
243             Returns the product at the specified index.
244              
245             =head2 product_grep( sub {...})
246              
247             This method returns every element matching a given criteria, just like Perl's core grep function. This method requires a subroutine which implements the matching logic. The returned list is provided as a Collection::Array object.
248              
249             =head2 product_index( sub {...})
250              
251             This method returns the index of the first matching product in the cart. The matching is done with a subroutine reference you pass to this method. The subroutine will be called against each element in the array until one matches or all elements have been checked.
252              
253             This method requires a single argument.
254              
255             my $index = $cart->product_index( sub { $_->sku eq 'ABC' } );
256              
257             =head2 product_push($product)
258              
259             Like Perl's normal C this adds the supplied L
260             to L.
261              
262             =head2 product_set($index, $product)
263              
264             Sets the product at the specified index in L to the supplied
265             L.
266              
267             =head2 products_array
268              
269             Returns an array of Interchange::Cart::Product(s)
270              
271             =head2 new
272              
273             Inherited method. Returns a new Cart object.
274              
275             =head2 add($product)
276              
277             Add product to the cart. Returns product in case of success.
278              
279             The product is an L or a hash (reference) of product attributes that would be passed to Interchange6::Cart::Product->new().
280              
281             =cut
282              
283             sub add {
284 19     19 1 5367 my $self = shift;
285 19         26 my $product = $_[0];
286 19         16 my $update;
287              
288 19 100       73 croak "undefined argument passed to add" unless defined $product;
289              
290 17 100       77 $product = use_module( $self->product_class )->new(@_)
291             unless $product->$_isa( $self->product_class );
292              
293             # Cart may already contain an product with the same sku.
294             # If so then we add quantity to existing product otherwise we add new
295             # product.
296              
297 15 100       714 if ( $product->should_combine_by_sku ) {
298              
299             # product can be combined with existing product so look for one
300             # that also allows combining
301              
302             my $index = $self->product_index(
303 11 100   9   171 sub { $_->sku eq $product->sku && $_->should_combine_by_sku } );
  9         277  
304              
305 11 100       1513 if ( $index >= 0 ) {
306              
307             # product already exists in cart so we need to add new quantity to old
308              
309 2         30 my $oldproduct = $self->product_get($index);
310              
311 2         109 $product->set_quantity(
312             $oldproduct->quantity + $product->quantity );
313              
314 2         38 $self->product_set( $index, $product );
315              
316 2         37 $update = 1;
317             }
318             }
319              
320 15 100       34 if ( !$update ) {
321              
322             # a new product for this cart
323              
324 13         145 $product->set_cart($self);
325 13         1850 $self->product_push($product);
326             }
327              
328 15         927 $self->clear_subtotal;
329 15         580 $self->clear_weight;
330 15         77 return $product;
331             }
332              
333             =head2 find
334              
335             Searches for a cart product with the given SKU.
336             Returns cart product in case of sucess or undef on failure.
337              
338             if ($product = $cart->find(9780977920174)) {
339             print "Quantity: $product->quantity.\n";
340             }
341              
342             =cut
343              
344             sub find {
345 2     2 1 1176 my ( $self, $sku ) = @_;
346 2     1   26 $self->product_first( sub { $sku eq $_->sku } );
  1         40  
347             }
348              
349             =head2 has_subtotal
350              
351             predicate on L.
352              
353             =head2 has_total
354              
355             predicate on L.
356              
357             =head2 has_weight
358              
359             predicate on L.
360              
361             =head2 quantity
362              
363             Returns the sum of the quantity of all products in the shopping cart,
364             which is commonly used as number of products. If you have 5 apples and 6 pears it will return 11.
365              
366             print 'Products in your cart: ', $cart->quantity, "\n";
367              
368             =cut
369              
370             sub quantity {
371 27     27 1 10222 my $self = shift;
372              
373 27         30 my $qty = 0;
374 27         466 foreach my $product ( $self->products_array ) {
375 46         1433 $qty += $product->quantity;
376             }
377              
378 27         231 return $qty;
379             }
380              
381             =head2 remove
382              
383             Remove product from the cart. Takes SKU of product to identify the product.
384              
385             $self->remove('ABC123');
386              
387             =cut
388              
389             sub remove {
390 17     17 1 2032 my $self = shift;
391 17         13 my $index;
392              
393 17 100 100     98 croak "no argument passed to remove" unless @_ && defined($_[0]);
394              
395 15 100       31 my %args = ref($_[0]) eq '' ? ( sku => $_[0] ) : %{ $_[0] };
  11         33  
396              
397 15 100       38 if ( defined $args{index} ) {
    100          
    100          
398 6 100       47 croak "bad index supplied to remove" if $args{index} !~ /^\d+$/;
399              
400 3         4 $index = $args{index};
401             }
402             elsif ( defined $args{id} ) {
403             my @cart_products =
404 3 100   8   62 $self->product_grep( sub { defined $_->id && $_->id eq $args{id} } );
  8         142  
405              
406 3 100       20 if ( @cart_products == 1 ) {
    100          
407             $index = $self->product_index(
408 1 100   2   17 sub { defined $_->id && $_->id eq $args{id} } );
  2         43  
409             }
410             elsif ( @cart_products > 1 ) {
411 1         9 croak "Cannot remove product with non-unique id";
412             }
413             else {
414 1         10 croak "Product with id $args{id} not found in cart";
415             }
416             }
417             elsif ( defined $args{sku} ) {
418             my @cart_products =
419 5     10   102 $self->product_grep( sub { $_->sku eq $args{sku} } );
  10         199  
420              
421 5 100       34 if ( @cart_products == 1 ) {
    100          
422 2     2   30 $index = $self->product_index( sub { $_->sku eq $args{sku} } );
  2         69  
423             }
424             elsif ( @cart_products > 1 ) {
425 2         15 croak "Cannot remove product with non-unique sku";
426             }
427             else {
428 1         9 croak "Product with sku $args{sku} not found in cart";
429             }
430             }
431             else {
432 1         9 croak "Args to remove must include one of: index, id or sku";
433             }
434              
435 6         109 my $ret = $self->product_delete($index);
436              
437             # if we got here then product_delete really shouldn't fail
438             # uncoverable branch true
439 6 50       109 croak "remove failed" unless defined $ret;
440              
441 6         81 $self->clear_subtotal;
442 6         232 $self->clear_weight;
443 6         34 return $ret;
444             }
445              
446             =head2 seed $product_ref
447              
448             Seeds products within the cart from $product_ref.
449              
450             B use with caution since any existing products in the cart will be lost.
451              
452             $cart->seed([
453             { sku => 'BMX2015', price => 20, quantity = 1 },
454             { sku => 'KTM2018', price => 400, quantity = 5 },
455             { sku => 'DBF2020', price => 200, quantity = 5 },
456             ]);
457              
458             If any product fails to be added (for example bad product args) then an
459             exception is thrown and no products will be added to cart.
460              
461             On success returns L.
462              
463             =cut
464              
465             sub seed {
466 4     4 1 1066 my ( $self, $product_ref ) = @_;
467              
468 4 100       19 croak "argument to seed must be an array reference"
469             unless ref($product_ref) eq 'ARRAY';
470              
471 3         12 my $product_class = use_module( $self->product_class );
472              
473 3         62 my @products;
474 3         3 for my $args ( @{$product_ref} ) {
  3         6  
475 6         302 push @products, $product_class->new($args);
476             }
477 2         131 $self->_set_products( \@products );
478              
479 2         607 $self->clear_subtotal;
480 2         84 $self->clear_weight;
481 2         13 return $self->products;
482             }
483              
484             =head2 update
485              
486             Update quantity of products in the cart.
487              
488             Parameters are pairs of SKUs and quantities, e.g.
489              
490             $cart->update(9780977920174 => 5,
491             9780596004927 => 3);
492              
493             Or a list of hash references, e.g.
494              
495             $cart->update(
496             { index => 3, quantity => 2 },
497             { id => 73652, quantity => 1 },
498             { sku => 'AJ12', quantity => 4 },
499             );
500              
501             A quantity of zero is equivalent to removing this product.
502              
503             Returns an array of updated products that are still in the cart.
504             Products removed via quantity 0 or products for which quantity has not
505             changed will not be returned.
506              
507             If you have products that cannot be combined in the cart (see
508             L and
509             L) then it is possible to
510             have multiple cart products with the same sku. In this case the arguments
511             to L must be a list of hash references using either
512             L or C where C is
513             the zero-based index of the product within L.
514              
515             =cut
516              
517             sub update {
518 23     23 1 1229 my ( $self, @args ) = @_;
519 23         18 my @products;
520              
521 23         48 ARGS: while ( @args > 0 ) {
522              
523 24         22 my ( $product, $sku, $qty );
524              
525 24 100       51 if ( ref( $args[0] ) eq '' ) {
    100          
526              
527             # original API expecting list of sku/qty pairs
528              
529 14         13 $sku = shift @args;
530 14         13 $qty = shift @args;
531              
532 14 100       29 croak "sku not defined in arg to update" unless defined $sku;
533              
534 13     27   216 my @cart_products = $self->product_grep( sub { $_->sku eq $sku } );
  27         1198  
535              
536 13 100       82 if ( @cart_products == 0 ) {
    100          
537 1         11 croak "Product for $sku not found in cart.";
538             }
539             elsif ( @cart_products == 1 ) {
540              
541             # one matching product
542 11         18 $product = $cart_products[0];
543             }
544             else {
545 1         10 croak "More than one product in cart with sku $sku. ",
546             "You must pass a hash reference to the update method ",
547             "including the cart position/index to update this sku.";
548             }
549              
550             }
551             elsif ( ref( $args[0] ) eq 'HASH' ) {
552              
553             # a hash reference of items that should reference a single product
554              
555 9         8 my %selectors = %{ shift @args };
  9         26  
556              
557 9         13 $qty = delete $selectors{quantity};
558              
559 9 100       11 if ( defined $selectors{index} ) {
560              
561             # select by position in cart
562 5 100       30 croak "bad index for update" if $selectors{index} !~ /^\d+$/;
563              
564 4         73 $product = $self->product_get( $selectors{index} );
565             }
566             else {
567 4         3 my @cart_products;
568              
569 4 100       9 if ( defined $selectors{id} ) {
    100          
570              
571             # search by product id
572             @cart_products = $self->product_grep(
573 2 100   6   42 sub { defined $_->id && $_->id eq $selectors{id} } );
  6         97  
574             }
575             elsif ( defined $selectors{sku} ) {
576              
577             # search by product sku
578             @cart_products =
579 1     3   22 $self->product_grep( sub { $_->sku eq $selectors{sku} } );
  3         45  
580             }
581             else {
582 1         9 croak "Args to update must include index, id or sku";
583             }
584              
585 3 100       21 if ( @cart_products == 0 ) {
    100          
586 1         9 croak "Product not found in cart for update.";
587             }
588             elsif ( @cart_products == 1 ) {
589              
590             # one matching product
591 1         3 $product = $cart_products[0];
592             }
593             else {
594 1         7 croak "More than one product found in cart for update.",;
595             }
596             }
597              
598             }
599             else {
600 1         8 croak "Unexpected ", ref( $args[0] ), " argument to update";
601             }
602              
603 16 100       170 croak "Product not found for update" unless $product;
604              
605 15 100 100     76 defined($qty) && ref($qty) eq ''
606             or croak "quantity argument to update must be defined";
607              
608 12 100       20 if ( $qty == 0 ) {
609 1         5 $self->remove( $product->sku );
610 1         3 next;
611             }
612              
613             # jump to next product if quantity stays the same
614 11 100       35 next ARGS if $qty == $product->quantity;
615              
616 9         151 $product->set_quantity($qty);
617 8         121 push @products, $product;
618             }
619              
620 10         153 $self->clear_subtotal;
621 10         395 $self->clear_weight;
622 10         58 return @products;
623             }
624              
625             =head1 AUTHORS
626              
627             Stefan Hornburg (Racke),
628             Peter Mottram (SysPete),
629              
630             =head1 LICENSE AND COPYRIGHT
631              
632             Copyright 2011-2016 Stefan Hornburg (Racke) .
633              
634             This program is free software; you can redistribute it and/or modify it
635             under the terms of either: the GNU General Public License as published
636             by the Free Software Foundation; or the Artistic License.
637              
638             See http://dev.perl.org/licenses/ for more information.
639              
640             =cut
641              
642             1;