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