#!/usr/bin/perl # # DW::Shop::Cart # # Encapsulates a shopping cart for a user. Handles loading, saving, modifying # and all other actions of a shopping cart. # # Authors: # Mark Smith # Janine Smith # # Copyright (c) 2009-2010 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # package DW::Shop::Cart; use strict; use Carp qw/ croak confess /; use Storable qw/ nfreeze thaw /; use DW::Shop; # returns a created cart for a given shop sub get { my ( $class, $shop ) = @_; # see if the shop has a user or if it's anonymous my ( $u, $sql, @bind ); if ( $shop->anonymous ) { # if they don't have a unique cookie and they're anonymous, we aren't # presently equipped to let them shop my $uniq = LJ::UniqCookie->current_uniq or return undef; $sql = 'uniq = ? AND userid IS NULL'; @bind = ($uniq); } else { $u = $shop->u or confess 'shop has no user object'; # return this cart if loaded already return $u->{_cart} if $u->{_cart}; # faaail, have to load it $sql = 'userid = ?'; @bind = ( $u->id ); } # see if they had one in the database my $dbh = LJ::get_db_writer() or return undef; my $dbcart = $dbh->selectrow_hashref( qq{SELECT cartblob FROM shop_carts WHERE $sql AND state = ? ORDER BY starttime DESC LIMIT 1}, undef, @bind, $DW::Shop::STATE_OPEN ); # if we got something, thaw the blob and return if ($dbcart) { my $cart = $class->_build( thaw( $dbcart->{cartblob} ) ); if ($u) { $u->{_cart} = $cart; } return $cart; } # no existing cart, so build a new one \o/ return $class->new_cart($u); } # returns a new cart given a cartid sub get_from_cartid { my ( $class, $cartid ) = @_; return undef unless defined $cartid && $cartid > 0; # see if they had one in the database my $dbh = LJ::get_db_writer() or return undef; my $dbcart = $dbh->selectrow_hashref( qq{SELECT cartblob FROM shop_carts WHERE cartid = ?}, undef, $cartid ); return undef unless $dbcart; # if we got something, thaw the blob and return return $class->_build( thaw( $dbcart->{cartblob} ) ); } # returns a new cart given an ordernum sub get_from_ordernum { my ( $class, $ordernum ) = @_; my ( $cartid, $authcode ); ( $cartid, $authcode ) = ( $1 + 0, $2 ) if $ordernum =~ /^(\d+)-(.+)$/; return undef unless $cartid && $cartid > 0; return undef unless $authcode && length($authcode) == 20; # see if they had one in the database my $cart = $class->get_from_cartid($cartid); return undef unless $cart && $cart->authcode eq $authcode; # all matches, so return this cart return $cart; } # returns a new cart given an invite code # if scalar ref 'itemidref' is passed, store the itemid for the invite code in it sub get_from_invite { my ( $class, $code, %opts ) = @_; my $itemidref = $opts{itemidref}; my ($acid) = DW::InviteCodes->decode($code); return undef unless defined $acid && $acid > 0; my $dbh = LJ::get_db_writer() or return undef; my $dbret = $dbh->selectrow_hashref( qq{SELECT cartid, itemid FROM shop_codes WHERE acid = ?}, undef, $acid ); return undef unless $dbret; $$itemidref = $dbret->{itemid} if ref $itemidref eq 'SCALAR'; return $class->get_from_cartid( $dbret->{cartid} ); } # creating a new cart implicitly activates. just so you know. this function # will build a new empty cart for the user. but user is optional and we will # build a cart for the current uniq. sub new_cart { my ( $class, $u ) = @_; $u = LJ::want_user($u); my $cartid = LJ::alloc_global_counter('H') or return undef; # this is a blank cart containing no items my $cart = { cartid => $cartid, starttime => time(), userid => $u ? $u->id : undef, ip => LJ::get_remote_ip(), state => $DW::Shop::STATE_OPEN, items => [], total_cash => 0.00, total_points => 0, nextscan => 0, authcode => LJ::make_auth_code(20), paymentmethod => 0, # we don't have a payment method yet pm_metadata => {}, # payment method extra storage email => undef, # we don't have an email yet }; # if uniq undef, hash definition is totally wrecked, so set this separately $cart->{uniq} = LJ::UniqCookie->current_uniq; # now, delete any old carts we don't need my $dbh = LJ::get_db_writer() or return undef; if ( defined $cart->{userid} ) { $dbh->do( q{UPDATE shop_carts SET state = ? WHERE userid = ? AND state = ?}, undef, $DW::Shop::STATE_CLOSED, $cart->{userid}, $DW::Shop::STATE_OPEN ); croak $dbh->errstr if $dbh->err; } if ( defined $cart->{uniq} ) { $dbh->do( q{UPDATE shop_carts SET state = ? WHERE uniq = ? AND state = ?}, undef, $DW::Shop::STATE_CLOSED, $cart->{uniq}, $DW::Shop::STATE_OPEN ); croak $dbh->errstr if $dbh->err; } # build this into an object and activate it $cart = $class->_build($cart); # now persist the cart $cart->save; $u->{_cart} = $cart if $u; DW::Stats::increment( 'dw.shop.cart.new', 1, [ 'anonymous:' . ( $u ? 'no' : 'yes' ) ] ); # we're done return $cart; } # returns all carts that the given user has ever had # can pass 'finished' opt which will omit carts in the OPEN, CLOSED, or # CHECKOUT states sub get_all { my ( $class, $u, %opts ) = @_; $u = LJ::want_user($u); my $extra_sql = $opts{finished} ? " AND state NOT IN ($DW::Shop::STATE_OPEN," . " $DW::Shop::STATE_CLOSED," . " $DW::Shop::STATE_CHECKOUT)" : ""; my $dbh = LJ::get_db_writer() or return undef; my $sth = $dbh->prepare("SELECT cartblob FROM shop_carts WHERE userid = ?$extra_sql"); $sth->execute( $u->id ); my @carts = (); while ( my $cart = $sth->fetchrow_hashref ) { push @carts, $class->_build( thaw( $cart->{cartblob} ) ); } return @carts; } # saves the current cart to the database, returns 1/0 sub save { my ( $self, %opts ) = @_; # we store the payment method id in the db my $paymentmethod_id = $DW::Shop::PAYMENTMETHODS{ $self->paymentmethod }->{id} || 0; # toss in the database my $dbh = LJ::get_db_writer() or return undef; $dbh->do( q{REPLACE INTO shop_carts (userid, cartid, starttime, uniq, state, nextscan, authcode, email, paymentmethod, cartblob) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)}, undef, ( map { $self->{$_} } qw/ userid cartid starttime uniq state nextscan authcode email / ), $paymentmethod_id, nfreeze($self) ); # bail if error return 0 if $dbh->err; return 1; } # returns an engine for this cart sub engine { my $self = $_[0]; return $self->{_engine} ||= DW::Shop::Engine->get( $self->paymentmethod => $self ); } # returns the number of items in this cart sub num_items { my $self = $_[0]; return scalar @{ $self->{items} || [] }; } # returns 1/0 if this cart has any items in it sub has_items { my $self = $_[0]; return $self->num_items > 0 ? 1 : 0; } # add an item to the shopping cart, returns 1/0 sub add_item { my ( $self, $item ) = @_; return unless $self && $item; die "Attempted to alter cart not in OPEN state.\n" unless $self->state == $DW::Shop::STATE_OPEN; # tell the item who we are $item->cartid( $self->id ); # make sure this item is allowed to be added my $error; unless ( $item->can_be_added( errref => \$error, user_confirmed => delete $item->{user_confirmed} ) ) { return ( 0, $error ); } # iterate over existing items to see if any conflict foreach my $it ( @{ $self->items } ) { if ( my $rv = $it->conflicts($item) ) { # this return value is so messed up... WTB exceptions return ( 0, $rv ); } } # construct a new, unique id for this item my $itid = LJ::alloc_global_counter('I') or return ( 0, 'Failed to allocate item counter.' ); $item->id($itid); # looks good, so let's add it... push @{ $self->items }, $item; $self->recalculate_costs; # now call out to the hook system in case anybody wants to munge with us LJ::Hooks::run_hooks( 'shop_cart_added_item', $self, $item ); # save to db and return $self->_touch; $self->save || return ( 0, 'Unable to save cart.' ); return 1; } # removes an item from this cart by id sub remove_item { my ( $self, $id, %opts ) = @_; return unless $self && $id; die "Attempted to alter cart not in OPEN state.\n" unless $self->state == $DW::Shop::STATE_OPEN; my ( $removed, $out ) = ( undef, [] ); foreach my $it ( @{ $self->items } ) { if ( $it->id == $id ) { # some items are noremove items if ( $it->noremove && !$opts{force} ) { push @$out, $it; next; } # advise that we removed an item from the cart die "Attempted to remove two items in one pass with id $id.\n" if defined $removed; $removed = $it; } else { push @$out, $it; } } $self->{items} = $out; # now recalculate the costs and save $self->recalculate_costs; $self->_touch; $self->save; # now run the hook, this is later so that we've updated the cart already LJ::Hooks::run_hooks( 'shop_cart_removed_item', $self, $removed ); return 1; } sub recalculate_costs { my $self = $_[0]; # if we're not in the OPEN state, do not recalculate. the prices are fixed. return unless $self->state == $DW::Shop::STATE_OPEN; my ( $has_points, $max_points ) = ( 0, 0 ); if ( $self->userid ) { my $u = LJ::load_userid( $self->userid ); $has_points = $u->shop_points; } # we have to determine the total cost of the order first so we can do the # minimum order size calculations later ( $self->{total_points}, $self->{total_cash} ) = ( 0, 0.00 ); foreach my $item ( @{ $self->items } ) { $self->{total_cash} += $item->paid_cash( $item->cost_cash ); $item->paid_points(0); $max_points += $item->cost_points; } # if the user has no points, we're done return unless $has_points; # now, if we're short on points, the maximum we can use is based on the # minimum cash order size my $all_points = 0; if ( $has_points < $max_points ) { # x10 to convert from USD to points my $cutoff = $max_points - ( $DW::Shop::MIN_ORDER_COST * 10 ); # now we effectively constrain the ceiling of how many points the user # has to the point that makes the cash equivalent $3.00 $has_points = $cutoff if $has_points > $cutoff; } else { # user is all poitns $all_points = 1; } # second loop has to iterate and actually adjust the point/cash balances foreach my $item ( @{ $self->items } ) { # in some cases, we have items that cost no points, those items # we can just ignore and skip next unless $item->cost_points; # start deducting items from points until one goes negative. note that # every item has to cost at least 1 cent, or Stripe will be unhappy. $has_points -= $item->cost_points; # if positive, the item was paid for by points entirely, but we need # to respect Stripe's minimum cost rules if ( $has_points >= 0 ) { if ($all_points) { # No cash $item->paid_cash(0.0); $item->paid_points( $item->cost_points ); $self->{total_cash} -= $item->cost_cash; $self->{total_points} += $item->cost_points; } else { # Respect Stripe minimum cost $item->paid_cash(0.1); $item->paid_points( $item->cost_points - 1 ); $has_points++; $self->{total_cash} -= ( $item->cost_cash - 0.1 ); $self->{total_points} += ( $item->cost_points - 1 ); } # and last if we're at 0 points left last if $has_points == 0; } else { my $cash = -$has_points; $item->paid_cash( $cash / 10 ); $item->paid_points( $item->cost_points - $cash ); $self->{total_cash} -= $item->cost_cash - $item->paid_cash; $self->{total_points} += $item->paid_points; # and this means we're done last; } } } # given an itemid that's in this cart, return it sub get_item { my ( $self, $id ) = @_; foreach my $it ( @{ $self->items } ) { return $it if $it->id == $id; } return undef; } # get/set state sub state { my ( $self, $newstate ) = @_; return $self->{state} unless defined $newstate; return $self->{state} if $self->{state} == $newstate; # alert the items that the cart's state has changed, this allows items to do things # that happen when the state changes. $_->cart_state_changed($newstate) foreach @{ $self->items }; LJ::Hooks::run_hooks( 'shop_cart_state_change', $self, $newstate ); DW::Stats::increment( 'dw.shop.cart.state_change', 1, [ 'from_state:' . $DW::Shop::STATE_NAMES{ $self->{state} }, 'to_state:' . $DW::Shop::STATE_NAMES{$newstate} ] ); $self->_notify_buyer_paid if $newstate == $DW::Shop::STATE_PROCESSED; $self->{state} = $newstate; $self->save; return $self->{state}; } # get/set payment method sub paymentmethod { my ( $self, $newpaymentmethod ) = @_; return $self->{paymentmethod} unless defined $newpaymentmethod; $self->{paymentmethod} = $newpaymentmethod; $self->save; return $self->{paymentmethod}; } # return hash for paymenet method metadata sub paymentmethod_metadata { my ( $self, $key, $val ) = @_; if ( defined $val ) { $self->{pm_metadata}->{$key} = $val; $self->save; } return $self->{pm_metadata}->{$key}; } # payment method the user should be aware of sub paymentmethod_visible { my $self = $_[0]; my $paymentmethod = $self->{paymentmethod}; return $paymentmethod unless $paymentmethod eq "checkmoneyorder"; return ( $self->total_cash == 0 ) ? "points" : $paymentmethod; } # get/set email address sub email { my ( $self, $newemail ) = @_; return $self->{email} unless defined $newemail; $self->{email} = $newemail; $self->save; return $self->{email}; } ################################################################################ ## read-only accessor methods ################################################################################ sub id { $_[0]->{cartid} } sub userid { $_[0]->{userid} } sub starttime { $_[0]->{starttime} } sub age { time() - $_[0]->{starttime} } sub items { $_[0]->{items} ||= [] } sub ip { $_[0]->{ip} } sub uniq { $_[0]->{uniq} } sub nextscan { $_[0]->{nextscan} } sub authcode { $_[0]->{authcode} } sub total_points { $_[0]->{total_points} + 0 } sub ordernum { $_[0]->{cartid} . '-' . $_[0]->{authcode} } # this has to work for both old items (pre-points) and new ones sub total_cash { my $self = $_[0]; return $self->{total} + 0.00 if exists $self->{total}; return $self->{total_cash} + 0.00; } # returns the total in a displayed format sub display_total { my $self = $_[0]; if ( $self->total_cash && $self->total_points ) { return sprintf( '$%0.2f USD and %d points', $self->total_cash, $self->total_points ); } elsif ( $self->total_cash ) { return sprintf( '$%0.2f USD', $self->total_cash ); } elsif ( $self->total_points ) { return sprintf( '%d points', $self->total_points ); } else { return 'free'; } } sub display_total_cash { sprintf( '$%0.2f USD', $_[0]->total_cash ) } sub display_total_points { sprintf( '%d points', $_[0]->total_points ) } ################################################################################ ## internal cart methods ################################################################################ # turns a hashref cart into a cart object sub _build { my ( $class, $cart ) = @_; ref $cart eq 'HASH' or return $cart; # simply blesses ... although in the future we might do some sanity checking # here to make sure we have good data, if that proves to be necessary. return bless $cart, $class; } # called to update our access time, this is mostly an internal method, but anybody # that has reason to can call it; note that this needs to be called before a save sub _touch { $_[0]->{starttime} = time; } # let the cart owner know that their purchase has just gone through. sub _notify_buyer_paid { my $self = $_[0]; my $u = LJ::load_userid( $self->{userid} ); my @payment_methods; push @payment_methods, '$' . $self->total_cash . ' USD' if $self->total_cash; push @payment_methods, $self->total_points . ' points' if $self->total_points; my $itemlist = join( "\n", map { " * " . $_->short_desc( nohtml => 1 ) } @{ $self->items } ); LJ::send_mail( { to => $self->email, from => $LJ::ACCOUNTS_EMAIL, fromname => $LJ::SITENAME, subject => LJ::Lang::ml( "shop.email.processed.subject", { sitename => $LJ::SITENAME } ), body => LJ::Lang::ml( "shop.email.processed.body", { touser => LJ::isu($u) ? $u->display_name : $self->email, price => join( ", ", @payment_methods ), itemlist => $itemlist, receipturl => "$LJ::SHOPROOT/receipt?ordernum=" . $self->ordernum, sitename => $LJ::SITENAME, } ), } ) unless $LJ::T_SUPPRESS_EMAIL; } 1;