The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
# Nitesi::Cart - Nitesi cart class

package Nitesi::Cart;

use strict;
use warnings;

use constant CART_DEFAULT => 'main';

=head1 NAME 

Nitesi::Cart - Cart class for Nitesi Shop Machine

=head1 DESCRIPTION

Generic cart class for L<Nitesi>.

=head2 CART ITEMS

Each item in the cart has at least the following attributes:

=over 4

=item sku

Unique item identifier.

=item name

Item name.

=item quantity

Item quantity.

=item price

Item price.

=back

=head1 CONSTRUCTOR

=head2 new

=cut

sub new {
    my ($class, $self, %args);

    $class = shift;
    %args = @_;

    $self = {error => '', items => [], modifiers => [],
	     costs => [], subtotal => 0, total => 0, 
	     cache_subtotal => 1, cache_total => 1,
    };

    if ($args{name}) {
	$self->{name} = $args{name};
    }
    else {
	$self->{name} = CART_DEFAULT;
    }

    if ($args{modifiers}) {
	$self->{modifiers} = $args{modifiers};
    }

    if ($args{run_hooks}) {
	$self->{run_hooks} = $args{run_hooks};
    }

    bless $self, $class;

    $self->init(%args);

    return $self;
}

=head2 init

Initializer which receives the constructor arguments, but does nothing.
May be overridden in a subclass.

=cut

sub init {
    return 1;
};

=head2 items

Returns items in the cart.

=cut

sub items {
    my ($self) = shift;

    return $self->{items};
}

=head2 subtotal

Returns subtotal of the cart.

=cut

sub subtotal {
    my ($self) = shift;

    if ($self->{cache_subtotal}) {
	return $self->{subtotal};
    }

    $self->{subtotal} = 0;

    for my $item (@{$self->{items}}) {
	$self->{subtotal} += $item->{price} * $item->{quantity};
    }

    $self->{cache_subtotal} = 1;

    return $self->{subtotal};
}

=head2 total

Returns total of the cart.

=cut

sub total {
    my ($self) = shift;
    my ($subtotal);

    if ($self->{cache_total}) {
	return $self->{total};
    }

    $self->{total} = $subtotal = $self->subtotal();

    # calculate costs
    $self->{total} += $self->_calculate($subtotal);

    $self->{cache_total} = 1;

    return $self->{total};
}
 
=head2 add $item

Add item to the cart. Returns item in case of success.

The item is a hash (reference) which is subject to the following
conditions:

=over 4

=item sku

Item identifier is required.

=item name

Item name is required.

=item quantity

Item quantity is optional and has to be a natural number greater
than zero. Default for quantity is 1.

=item price

Item price is required and a positive number.

=back

=cut

sub add {
    my $self = shift;
    my (%item, $ret);

    if (ref($_[0])) {
	# copy item
	%item = %{$_[0]};
    }
    else {
	%item = @_;
    }

    # run hooks before validating item
    $self->_run_hook('before_cart_add_validate', $self, \%item);

    # validate item
    unless (exists $item{sku} && defined $item{sku} && $item{sku} =~ /\S/) {
	$self->{error} = 'Item added without SKU.';
	return;
    }

    unless (exists $item{name} && defined $item{name} && $item{name} =~ /\S/) {
	$self->{error} = "Item $item{sku} added without a name.";
	return;
    }

    if (exists $item{quantity} && defined $item{quantity}) {
	unless ($item{quantity} =~ /^(\d+)$/ && $item{quantity} > 0) {
	    $self->{error} = "Item $item{sku} added with invalid quantity $item{quantity}.";
	    return;
	}
    }
    else {
	$item{quantity} = 1;
    }

    unless (exists $item{price} && defined $item{price}
	    && $item{price} =~ /^(\d+)(\.\d+)?$/ && $item{price} > 0) {
	$self->{error} = "Item $item{sku} added with invalid price.";
	return;
    }
  
    # run hooks before adding item to cart
    $self->_run_hook('before_cart_add', $self, \%item);

    if (exists $item{error}) {
	# one of the hooks denied the item
	$self->{error} = $item{error};
	return;
    }

    # clear cache flags
    $self->{cache_subtotal} = $self->{cache_total} = 0;

    unless ($ret = $self->_combine(\%item)) {
	push @{$self->{items}}, \%item;
    }

    # run hooks after adding item to cart
    $self->_run_hook('after_cart_add', $self, \%item, $ret);

    return \%item;
}

=head2 remove $sku

Remove item from the cart. Takes SKU of item to identify the item.

=cut

sub remove {
    my ($self, $arg) = @_;
    my ($pos, $found, $item);

    $pos = 0;
  
    # run hooks before locating item
    $self->_run_hook('before_cart_remove_validate', $self, $arg);

    for $item (@{$self->{items}}) {
	if ($item->{sku} eq $arg) {
	    $found = 1;
	    last;
	}
	$pos++;
    }

    if ($found) {
	# run hooks before adding item to cart
	$item = $self->{items}->[$pos];

	$self->_run_hook('before_cart_remove', $self, $item);

	if (exists $item->{error}) {
	    # one of the hooks denied removing the item
	    $self->{error} = $item->{error};
	    return;
	}

	# clear cache flags
	$self->{cache_subtotal} = $self->{cache_total} = 0;

	# removing item from our array
	splice(@{$self->{items}}, $pos, 1);

	$self->_run_hook('after_cart_remove', $self, $item);
	return 1;
    }

    # item missing
    $self->{error} = "Missing item $arg.";

    return;
}

=head2 update

Update items in the cart.

Parameters are pairs of SKUs and quantities, e.g.

    $cart->update(9780977920174 => 5,
                  9780596004927 => 3);

Triggers before_cart_update and after_cart_update hooks.

A quantity of zero is equivalent to removing this item,
so in this case the remove hooks will be invoked instead 
of the update hooks.

=cut

sub update {
    my ($self, @args) = @_;
    my ($ref, $sku, $qty, $item, $new_item);

    while (@args > 0) {
	$sku = shift @args;
	$qty = shift @args;

	unless ($item = $self->_find($sku)) {
	    die "Item for $sku not found in cart.\n";
	}

	if ($qty == 0) {
	    # remove item instead
            $self->remove($sku);
	    next;
	}

	# jump to next item if quantity stays the same
	next if $qty == $item->{quantity};

	# run hook before updating the cart
	$new_item = {quantity => $qty};

	$self->_run_hook('before_cart_update', $self, $item, $new_item);

	if (exists $new_item->{error}) {
	    # one of the hooks denied the item
	    $self->{error} = $new_item->{error};
	    return;
	}

	$self->_run_hook('after_cart_update', $self, $item, $new_item);

	$item->{quantity} = $qty;
    }
}

=head2 clear

Removes all items from the cart.

=cut

sub clear {
    my ($self) = @_;

    # run hook before clearing the cart
    $self->_run_hook('before_cart_clear', $self);
    
    $self->{items} = [];

    # run hook after clearing the cart
    $self->_run_hook('after_cart_clear', $self);

    # reset subtotal/total
    $self->{subtotal} = 0;
    $self->{total} = 0;
    $self->{cache_subtotal} = 1;
    $self->{cache_total} = 1;

    return;
}

=head2 quantity

Returns the sum of the quantity of all items in the shopping cart,
which is commonly used as number of items.

    print 'Items in your cart: ', $cart->quantity, "\n";

=cut

sub quantity {
    my $self = shift;
    my $qty = 0;

    for my $item (@{$self->{items}}) {
	$qty += $item->{quantity};
    }

    return $qty;
}

=head2 count

Returns the number of different items in the shopping cart.

=cut

sub count {
    my $self = shift;

    return scalar(@{$self->{items}});
}

=head2 apply_cost 

Apply cost to cart.

Absolute cost:

    $cart->apply_cost(amount => 5, name => 'shipping', label => 'Shipping');

Relative cost:

    $cart->apply_cost(amount => 0.19, name => 'tax', label => 'Sales Tax',
                      relative => 1);

Inclusive cost:

   $cart->apply_cost(amount => 0.19, name => 'tax', label => 'Sales Tax',
                      relative => 1, inclusive => 1);

=cut

sub apply_cost {
    my ($self, %args) = @_;

    push @{$self->{costs}}, \%args;

    unless ($args{inclusive}) {
	# clear cache for total
	$self->{cache_total} = 0;
    }
}

=head2 clear_cost

Clear costs.

=cut

sub clear_cost {
    my $self = shift;

    $self->{costs} = [];

    $self->{cache_total} = 0;
}

=head2 cost

Returns particular cost by position or by name.

=cut

sub cost {
    my ($self, $loc) = @_;
    my ($cost, $ret);

    if (defined $loc) {
	if ($loc =~ /^\d+/) {
	    # cost by position
	    $cost = $self->{costs}->[$loc];
	}
	elsif ($loc =~ /\S/) {
	    # cost by name
	    for my $c (@{$self->{costs}}) {
		if ($c->{name} eq $loc) {
		    $cost = $c;
		}
	    }
	}
    }

    if (defined $cost) {
	$ret = $self->_calculate($self->{subtotal}, $cost, 1);
    }

    return $ret;
}

=head2 id

Get or set id of the cart. This can be used for subclasses, 
e.g. primary key value for carts in the database.

=cut

sub id {
    my $self = shift;

    if (@_ > 0) {
	$self->{id} = $_[0];
    }

    return $self->{id};
}

=head2 name

Get or set the name of the cart.

=cut

sub name {
    my $self = shift;

    if (@_ > 0) {
	my $old_name = $self->{name};

	$self->_run_hook('before_cart_rename', $self, $old_name, $_[0]);

	$self->{name} = $_[0];

	$self->_run_hook('after_cart_rename', $self, $old_name, $_[0]);
    }

    return $self->{name};
}

=head2 error

Returns last error.

=cut

sub error {
    my $self = shift;

    return $self->{error};
}

=head2 seed $item_ref

Seeds items within the cart from $item_ref.

=cut

sub seed {
    my ($self, $item_ref) = @_;

    @{$self->{items}} = @{$item_ref || []};

    # clear cache flags
    $self->{cache_subtotal} = $self->{cache_total} = 0;

    return $self->{items};
}

sub _find {
    my ($self, $sku) = @_;

    for my $cartitem (@{$self->{items}}) {
	if ($sku eq $cartitem->{sku}) {
	    return $cartitem;
        }
    }

    return;
}

sub _combine {
    my ($self, $item) = @_;

    ITEMS: for my $cartitem (@{$self->{items}}) {
	if ($item->{sku} eq $cartitem->{sku}) {
	    for my $mod (@{$self->{modifiers}}) {
		next ITEMS unless($item->{$mod} eq $cartitem->{$mod});
	    }					
	    			
	    $cartitem->{'quantity'} += $item->{'quantity'};
	    $item->{'quantity'} = $cartitem->{'quantity'};

	    return 1;
	}
    }

    return 0;
}

sub _calculate {
    my ($self, $subtotal, $costs, $display) = @_;
    my ($cost_ref, $sum);

    if (ref $costs eq 'HASH') {
	$cost_ref = [$costs];
    }
    elsif (ref $costs eq 'ARRAY') {
	$cost_ref = $costs;
    }
    else {
	$cost_ref = $self->{costs};
    }

    $sum = 0;

    for my $calc (@$cost_ref) {
	if ($calc->{inclusive} && ! $display) {
	    next;
	}

	if ($calc->{relative}) {
	    $sum += $subtotal * $calc->{amount};
        }
	else {
	    $sum += $calc->{amount};
	}
    }

    return $sum;
}

sub _run_hook {
    my ($self, $name, @args) = @_;
    my $ret;

    if ($self->{run_hooks}) {
	$ret = $self->{run_hooks}->($name, @args);
    }

    return $ret;
}

=head1 AUTHOR

Stefan Hornburg (Racke), <racke@linuxia.de>

=head1 LICENSE AND COPYRIGHT

Copyright 2011-2012 Stefan Hornburg (Racke) <racke@linuxia.de>.

This program is free software; you can redistribute it and/or modify it
under the terms of either: the GNU General Public License as published
by the Free Software Foundation; or the Artistic License.

See http://dev.perl.org/licenses/ for more information.

=cut

1;