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