]>
Commit | Line | Data |
---|---|---|
41b9d8ec TC |
1 | #!/usr/bin/perl -w |
2 | use strict; | |
691cde19 TC |
3 | use FindBin; |
4 | use lib "$FindBin::Bin/modules"; | |
41b9d8ec | 5 | use CGI ':standard'; |
41b9d8ec TC |
6 | use Products; |
7 | use Product; | |
edc5d096 | 8 | use Constants qw(:shop $TMPLDIR %EXTRA_TAGS $CGI_URI $URLBASE); |
41b9d8ec | 9 | use Squirrel::Template; |
41b9d8ec TC |
10 | use Squirrel::ImageEditor; |
11 | use CGI::Cookie; | |
c7adeba6 | 12 | use BSE::Custom; |
a82753e6 | 13 | use BSE::Mail; |
2aea9be6 | 14 | use BSE::Shop::Util qw/shop_cart_tags cart_item_opts nice_options total |
edc5d096 TC |
15 | basic_tags load_order_fields need_logon/; |
16 | use BSE::Session; | |
17 | use BSE::Cfg; | |
18 | use Util qw/refresh_to/; | |
41b9d8ec TC |
19 | |
20 | my $subject = $SHOP_MAIL_SUBJECT; | |
21 | ||
22 | # our PGP passphrase | |
23 | my $passphrase = $SHOP_PASSPHRASE; | |
24 | ||
25 | # the class we use to perform encryption | |
26 | # we can change this to switch between GnuPG and PGP | |
27 | my $crypto_class = $SHOP_CRYPTO; | |
28 | ||
29 | # id of the private key to use for signing | |
30 | # leave as undef to use your default key | |
31 | my $signing_id = $SHOP_SIGNING_ID; | |
32 | ||
41b9d8ec TC |
33 | # location of PGP |
34 | my $pgpe = $SHOP_PGPE; | |
35 | my $pgp = $SHOP_PGP; | |
36 | my $gpg = $SHOP_GPG; | |
37 | ||
38 | my $from = $SHOP_FROM; | |
39 | ||
40 | my $toName = $SHOP_TO_NAME; | |
41 | my $toEmail= $SHOP_TO_EMAIL; | |
42 | ||
edc5d096 | 43 | my $cfg = BSE::Cfg->new(); |
41b9d8ec | 44 | my %session; |
edc5d096 | 45 | BSE::Session->tie_it(\%session, $cfg); |
41b9d8ec TC |
46 | |
47 | # this shouldn't be necessary, but it stopped working elsewhere and this | |
48 | # fixed it | |
49 | END { | |
50 | untie %session; | |
51 | } | |
52 | ||
53 | if (!exists $session{cart}) { | |
54 | $session{cart} = []; | |
55 | } | |
56 | ||
57 | # the keys here are the names of the buttons on the various forms | |
58 | # we also have 'delete_<number>' buttons. | |
59 | my %steps = | |
60 | ( | |
61 | add=>\&add_item, | |
62 | cart=>\&show_cart, | |
63 | checkout=>\&checkout, | |
cd6734fc | 64 | recheckout => sub { checkout('', 1); }, |
ca849431 | 65 | confirm => \&checkout_confirm, |
41b9d8ec | 66 | recalc=>\&recalc, |
ad80cda7 | 67 | recalculate=>\&recalc, |
41b9d8ec | 68 | purchase=>\&purchase, |
691cde19 | 69 | prePurchase=>\&prePurchase, |
41b9d8ec TC |
70 | ); |
71 | ||
72 | for my $key (keys %steps) { | |
c7adeba6 | 73 | if (param($key) or param("$key.x")) { |
41b9d8ec TC |
74 | $steps{$key}->(); |
75 | exit; | |
76 | } | |
77 | } | |
78 | ||
79 | for my $key (param()) { | |
80 | if ($key =~ /^delete_(\d+)/) { | |
81 | remove_item($1); | |
82 | exit; | |
83 | } | |
84 | } | |
85 | ||
86 | show_cart(); | |
87 | ||
88 | sub add_item { | |
89 | my $addid = param('id'); | |
90 | my $quantity = param('quantity'); | |
91 | my $product; | |
92 | $product = Products->getByPkey($addid) if $addid; | |
93 | $product or return show_cart(); # oops | |
d7c3b6f9 TC |
94 | |
95 | # collect the product options | |
96 | my @options = map scalar param($_), split /,/, $product->{options}; | |
97 | grep(!defined, @options) | |
98 | and return show_cart(); # invalid parameter | |
99 | my $options = join(",", @options); | |
41b9d8ec TC |
100 | |
101 | # the product must be non-expired and listed | |
102 | my $today = epoch_to_sql(time); | |
103 | $product->{release} le $today and $today le $product->{expire} | |
104 | or return show_cart(); | |
105 | $product->{listed} or return show_cart(); | |
106 | ||
107 | # we need a natural integer quantity | |
108 | $quantity =~ /^\d+$/ | |
109 | or return show_cart(); | |
110 | ||
111 | my @cart = @{$session{cart}}; | |
112 | ||
113 | # if this is is already present, replace it | |
d7c3b6f9 TC |
114 | @cart = grep { $_->{productId} ne $addid || $_->{options} ne $options } |
115 | @cart; | |
41b9d8ec | 116 | push(@cart, { productId => $addid, units => $quantity, |
d7c3b6f9 TC |
117 | price=>$product->{retailPrice}, |
118 | options=>$options }); | |
119 | ||
41b9d8ec TC |
120 | $session{cart} = \@cart; |
121 | show_cart(); | |
122 | } | |
123 | ||
41b9d8ec TC |
124 | sub show_cart { |
125 | my @cart = @{$session{cart}}; | |
126 | my @cart_prods = map { Products->getByPkey($_->{productId}) } @cart; | |
127 | my $item_index = -1; | |
d7c3b6f9 TC |
128 | my @options; |
129 | my $option_index; | |
c7adeba6 TC |
130 | |
131 | $session{custom} ||= {}; | |
132 | my %custom_state = %{$session{custom}}; | |
133 | ||
134 | BSE::Custom->enter_cart(\@cart, \@cart_prods, \%custom_state); | |
41b9d8ec TC |
135 | |
136 | my %acts; | |
137 | %acts = | |
138 | ( | |
c7adeba6 | 139 | BSE::Custom->cart_actions(\%acts, \@cart, \@cart_prods, \%custom_state), |
d7c3b6f9 TC |
140 | iterate_items_reset => sub { $item_index = -1; }, |
141 | iterate_items => | |
142 | sub { | |
143 | if (++$item_index < @cart) { | |
144 | $option_index = -1; | |
145 | @options = cart_item_opts($cart[$item_index], | |
146 | $cart_prods[$item_index]); | |
147 | return 1; | |
148 | } | |
149 | return 0; | |
150 | }, | |
41b9d8ec TC |
151 | item => |
152 | sub { $cart[$item_index]{$_[0]} || $cart_prods[$item_index]{$_[0]} }, | |
691cde19 TC |
153 | extended => |
154 | sub { | |
155 | my $what = $_[0] || 'retailPrice'; | |
156 | $cart[$item_index]{units} * $cart_prods[$item_index]{$what}; | |
157 | }, | |
41b9d8ec | 158 | index => sub { $item_index }, |
c7adeba6 | 159 | total => sub { total(\@cart, \@cart_prods, \%custom_state) }, |
41b9d8ec TC |
160 | money => |
161 | sub { | |
162 | my ($func, $args) = split ' ', $_[0], 2; | |
163 | $acts{$func} || return "<: money $_[0] :>"; | |
164 | return sprintf("%.02f", $acts{$func}->($args)/100); | |
165 | }, | |
166 | count => sub { scalar @cart }, | |
d7c3b6f9 TC |
167 | iterate_options_reset => sub { $option_index = -1 }, |
168 | iterate_options => sub { ++$option_index < @options }, | |
169 | option => sub { CGI::escapeHTML($options[$option_index]{$_[0]}) }, | |
170 | ifOptions => sub { @options }, | |
171 | options => sub { nice_options(@options) }, | |
41b9d8ec | 172 | ); |
c7adeba6 TC |
173 | $session{custom} = \%custom_state; |
174 | ||
41b9d8ec TC |
175 | page('cart.tmpl', \%acts); |
176 | } | |
177 | ||
178 | sub update_quantities { | |
179 | my @cart = @{$session{cart}}; | |
180 | for my $index (0..$#cart) { | |
181 | my $new_quantity = param("quantity_$index"); | |
182 | if (defined $new_quantity) { | |
183 | if ($new_quantity =~ /^\s*(\d+)/) { | |
184 | $cart[$index]{units} = $1; | |
185 | } | |
186 | elsif ($new_quantity =~ /^\s*$/) { | |
187 | $cart[$index]{units} = 0; | |
188 | } | |
189 | } | |
41b9d8ec TC |
190 | } |
191 | @cart = grep { $_->{units} != 0 } @cart; | |
192 | $session{cart} = \@cart; | |
c7adeba6 TC |
193 | $session{custom} ||= {}; |
194 | my %custom_state = %{$session{custom}}; | |
195 | BSE::Custom->recalc($CGI::Q, \@cart, [], \%custom_state); | |
196 | $session{custom} = \%custom_state; | |
41b9d8ec TC |
197 | } |
198 | ||
199 | sub recalc { | |
200 | update_quantities(); | |
201 | show_cart(); | |
202 | } | |
203 | ||
204 | sub remove_item { | |
205 | my ($index) = @_; | |
206 | my @cart = @{$session{cart}}; | |
207 | if ($index >= 0 && $index < @cart) { | |
208 | splice(@cart, $index, 1); | |
209 | } | |
210 | $session{cart} = \@cart; | |
211 | ||
212 | print "Refresh: 0; url=\"$ENV{SCRIPT_NAME}\"\n"; | |
213 | print "Content-Type: text/html\n\n<html> </html>\n"; | |
214 | } | |
215 | ||
216 | # display the checkout form | |
217 | # can also be called with an error message and a flag to fillin the old | |
218 | # values for the form elements | |
219 | sub checkout { | |
220 | my ($message, $olddata) = @_; | |
221 | ||
222 | $message = '' unless defined $message; | |
223 | ||
224 | update_quantities(); | |
225 | my @cart = @{$session{cart}}; | |
ad80cda7 TC |
226 | |
227 | @cart or return show_cart(); | |
228 | ||
41b9d8ec | 229 | my @cart_prods = map { Products->getByPkey($_->{productId}) } @cart; |
edc5d096 TC |
230 | |
231 | if (need_logon($cfg, \@cart, \@cart_prods, \%session)) { | |
232 | refresh_to("$URLBASE/cgi-bin/user.pl?message=". | |
233 | CGI::escape("Some of the products in your cart include downloadable files. Please logon or register before checkout.")); | |
234 | return; | |
235 | } | |
236 | ||
237 | my $user; | |
238 | if ($session{userid}) { | |
239 | require 'SiteUsers.pm'; | |
240 | $user = SiteUsers->getBy(userId=>$session{userid}); | |
241 | } | |
242 | ||
c7adeba6 TC |
243 | $session{custom} ||= {}; |
244 | my %custom_state = %{$session{custom}}; | |
245 | ||
246 | BSE::Custom->enter_cart(\@cart, \@cart_prods, \%custom_state); | |
247 | ||
41b9d8ec | 248 | my $item_index = -1; |
d7c3b6f9 TC |
249 | my @options; |
250 | my $option_index; | |
41b9d8ec TC |
251 | my %acts; |
252 | %acts = | |
253 | ( | |
d7c3b6f9 TC |
254 | iterate_items_reset => sub { $item_index = -1 }, |
255 | iterate_items => | |
256 | sub { | |
257 | if (++$item_index < @cart) { | |
258 | $option_index = -1; | |
259 | @options = cart_item_opts($cart[$item_index], | |
260 | $cart_prods[$item_index]); | |
261 | return 1; | |
262 | } | |
263 | return 0; | |
264 | }, | |
41b9d8ec TC |
265 | item => |
266 | sub { $cart[$item_index]{$_[0]} || $cart_prods[$item_index]{$_[0]} }, | |
691cde19 TC |
267 | extended => |
268 | sub { | |
269 | my $what = $_[0] || 'retailPrice'; | |
270 | $cart[$item_index]{units} * $cart_prods[$item_index]{$what}; | |
271 | }, | |
41b9d8ec | 272 | index => sub { $item_index }, |
c7adeba6 | 273 | total => sub { total(\@cart, \@cart_prods, \%custom_state) }, |
41b9d8ec TC |
274 | money => |
275 | sub { | |
276 | my ($func, $args) = split ' ', $_[0], 2; | |
277 | $acts{$func} || return "<: money $_[0] :>"; | |
278 | return sprintf("%.02f", $acts{$func}->($args)/100); | |
279 | }, | |
280 | count => sub { scalar @cart }, | |
281 | message => sub { $message }, | |
edc5d096 TC |
282 | old => sub { CGI::escapeHTML($olddata ? param($_[0]) : |
283 | $user && defined $user->{$_[0]} ? $user->{$_[0]} : '') }, | |
d7c3b6f9 TC |
284 | iterate_options_reset => sub { $option_index = -1 }, |
285 | iterate_options => sub { ++$option_index < @options }, | |
286 | option => sub { CGI::escapeHTML($options[$option_index]{$_[0]}) }, | |
287 | ifOptions => sub { @options }, | |
288 | options => sub { nice_options(@options) }, | |
a82753e6 | 289 | BSE::Custom->checkout_actions(\%acts, \@cart, \@cart_prods, \%custom_state, $CGI::Q), |
41b9d8ec | 290 | ); |
c7adeba6 | 291 | $session{custom} = \%custom_state; |
41b9d8ec TC |
292 | |
293 | page('checkout.tmpl', \%acts); | |
294 | } | |
295 | ||
ca849431 TC |
296 | # displays the data entered by the user so they can either confirm the |
297 | # details or redisplay the checkout page | |
298 | sub checkout_confirm { | |
299 | my %order; | |
300 | my $error; | |
301 | ||
302 | my @cart_prods; | |
303 | unless (load_order_fields(0, $CGI::Q, \%order, \%session, \@cart_prods, | |
304 | \$error)) { | |
305 | return checkout($error, 1); | |
306 | } | |
dec5de5e | 307 | ++$session{changed}; |
ca849431 TC |
308 | my @cart = @{$session{cart}}; |
309 | # display the confirmation page | |
310 | my %acts; | |
311 | %acts = | |
312 | ( | |
313 | order => sub { CGI::escapeHTML($order{$_[0]}) }, | |
314 | shop_cart_tags(\%acts, \@cart, \@cart_prods, \%session, $CGI::Q), | |
315 | basic_tags(\%acts), | |
dec5de5e TC |
316 | old => |
317 | sub { | |
318 | my $value = param($_[0]); | |
319 | defined $value or $value = ''; | |
320 | CGI::escapeHTML($value); | |
321 | }, | |
ca849431 | 322 | ); |
cd6734fc | 323 | page('checkoutconfirm.tmpl', \%acts); |
ca849431 TC |
324 | } |
325 | ||
691cde19 TC |
326 | # this can be used instead of the purchase page to work in 2 steps: |
327 | # - collect shipping details | |
328 | # - collect CC details | |
329 | # the collection of the CC details should go to another script that | |
330 | # processes the CC information and then displays the order complete | |
331 | # information | |
332 | # BUG!!: this duplicates the code in purchase() a great deal | |
333 | sub prePurchase { | |
c7adeba6 | 334 | my @required = BSE::Custom->required_fields($CGI::Q, $session{custom}); |
691cde19 TC |
335 | for my $field (@required) { |
336 | defined(param($field)) && length(param($field)) | |
337 | or return checkout("Field $field is required", 1); | |
338 | } | |
dec5de5e TC |
339 | if (grep /email/, @required) { |
340 | defined(param('email')) && param('email') =~ /.\@./ | |
341 | or return checkout("Please enter a valid email address", 1); | |
342 | } | |
343 | ||
691cde19 TC |
344 | use Orders; |
345 | use Order; | |
346 | use OrderItems; | |
347 | use OrderItem; | |
348 | ||
349 | # map some form fields to order field names | |
350 | my %field_map = | |
351 | ( | |
352 | name1 => 'delivFirstName', | |
353 | name2 => 'delivLastName', | |
354 | address => 'delivStreet', | |
355 | city => 'delivSuburb', | |
356 | postcode => 'delivPostCode', | |
357 | state => 'delivState', | |
358 | country => 'delivCountry', | |
359 | email => 'emailAddress', | |
360 | cardHolder => 'ccName', | |
361 | cardType => 'ccType', | |
362 | ); | |
363 | # paranoia, don't store these | |
364 | my %nostore = | |
365 | ( | |
366 | cardNumber => 1, | |
367 | cardExpiry => 1, | |
368 | ); | |
369 | my %order; | |
370 | my @cart = @{$session{cart}}; | |
371 | @cart or return show_cart('You have no items in your shopping cart'); | |
372 | ||
373 | # so we can quickly check for columns | |
374 | my @columns = Order->columns; | |
375 | my %columns; | |
376 | @columns{@columns} = @columns; | |
377 | ||
378 | for my $field (param()) { | |
379 | $order{$field_map{$field} || $field} = param($field) | |
380 | unless $nostore{$field}; | |
381 | } | |
382 | ||
383 | my $ccNumber = param('cardNumber'); | |
384 | my $ccExpiry = param('cardExpiry'); | |
385 | ||
386 | use Digest::MD5 'md5_hex'; | |
387 | $ccNumber =~ tr/0-9//cd; | |
388 | $order{ccNumberHash} = md5_hex($ccNumber); | |
389 | $order{ccExpiryHash} = md5_hex($ccExpiry); | |
390 | ||
391 | # work out totals | |
392 | $order{total} = 0; | |
393 | $order{gst} = 0; | |
394 | $order{wholesale} = 0; | |
395 | my @products; | |
396 | my $today = epoch_to_sql(time); | |
397 | for my $item (@cart) { | |
398 | my $product = Products->getByPkey($item->{productId}); | |
399 | # double check that it's still a valid product | |
400 | if (!$product) { | |
401 | return show_cart("Product $item->{productId} not found"); | |
402 | } | |
403 | elsif ($product->{release} gt $today || $product->{expire} lt $today | |
404 | || !$product->{listed}) { | |
405 | return show_cart("Sorry, '$product->{title}' is no longer available"); | |
406 | } | |
407 | push(@products, $product); # used in page rendering | |
408 | @$item{qw/price wholesalePrice gst/} = | |
409 | @$product{qw/retailPrice wholesalePrice gst/}; | |
410 | $order{total} += $item->{price} * $item->{units}; | |
411 | $order{wholesale} += $item->{wholesalePrice} * $item->{units}; | |
412 | $order{gst} += $item->{gst} * $item->{units}; | |
413 | } | |
414 | $order{orderDate} = $today; | |
415 | ||
edc5d096 TC |
416 | if (need_logon($cfg, \@cart, \@products, \%session)) { |
417 | refresh_to("$URLBASE/cgi-bin/user.pl?message=". | |
418 | CGI::escape("Some of the products in your cart include downloadable files. Please logon or register before checkout.")); | |
419 | return; | |
420 | } | |
421 | ||
c7adeba6 TC |
422 | $order{total} += BSE::Custom->total_extras(\@cart, \@products, |
423 | $session{custom}); | |
424 | ++$session{changed}; | |
691cde19 TC |
425 | # blank anything else |
426 | for my $column (@columns) { | |
427 | defined $order{$column} or $order{$column} = ''; | |
428 | } | |
429 | # make sure the user can't set these behind our backs | |
430 | $order{filled} = 0; | |
431 | $order{paidFor} = 0; | |
432 | ||
433 | # this should be hard to guess | |
434 | $order{randomId} = md5_hex(time().rand().{}.$$); | |
435 | ||
c7adeba6 TC |
436 | # check if a customizer has anything to do |
437 | eval { | |
438 | BSE::Custom->order_save($CGI::Q, \%order, \@cart, \@products, $session{custom}); | |
439 | ++$session{changed}; | |
440 | }; | |
441 | if ($@) { | |
442 | return checkout($@, 1); | |
443 | } | |
444 | ||
691cde19 TC |
445 | # load up the database |
446 | my @data = @order{@columns}; | |
447 | shift @data; # lose the dummy id | |
448 | my $order = Orders->add(@data) | |
449 | or die "Cannot add order"; | |
450 | my @items; | |
451 | my @item_cols = OrderItem->columns; | |
452 | for my $row (@cart) { | |
453 | $row->{orderId} = $order->{id}; | |
454 | my @data = @$row{@item_cols}; | |
455 | shift @data; | |
456 | push(@items, OrderItems->add(@data)); | |
457 | } | |
458 | ||
459 | my $item_index = -1; | |
460 | my @options; | |
461 | my $option_index; | |
462 | my %acts; | |
463 | %acts = | |
464 | ( | |
465 | iterate_items_reset => sub { $item_index = -1; }, | |
466 | iterate_items => | |
467 | sub { | |
468 | if (++$item_index < @items) { | |
469 | $option_index = -1; | |
470 | @options = cart_item_opts($items[$item_index], | |
471 | $products[$item_index]); | |
472 | return 1; | |
473 | } | |
474 | return 0; | |
475 | }, | |
476 | item=> sub { CGI::escapeHTML($items[$item_index]{$_[0]}); }, | |
477 | product => sub { CGI::escapeHTML($products[$item_index]{$_[0]}) }, | |
478 | extended => | |
479 | sub { | |
480 | my $what = $_[0] || 'retailPrice'; | |
481 | $items[$item_index]{units} * $items[$item_index]{$what}; | |
482 | }, | |
483 | order => sub { CGI::escapeHTML($order->{$_[0]}) }, | |
484 | money => | |
485 | sub { | |
486 | my ($func, $args) = split ' ', $_[0], 2; | |
487 | $acts{$func} || return "<: money $_[0] :>"; | |
488 | return sprintf("%.02f", $acts{$func}->($args)/100); | |
489 | }, | |
490 | old => sub { '' }, | |
491 | _format => | |
492 | sub { | |
493 | my ($value, $fmt) = @_; | |
494 | if ($fmt =~ /^m(\d+)/) { | |
495 | return sprintf("%$1s", sprintf("%.2f", $value/100)); | |
496 | } | |
497 | elsif ($fmt =~ /%/) { | |
498 | return sprintf($fmt, $value); | |
499 | } | |
500 | }, | |
501 | iterate_options_reset => sub { $option_index = -1 }, | |
502 | iterate_options => sub { ++$option_index < @options }, | |
503 | option => sub { CGI::escapeHTML($options[$option_index]{$_[0]}) }, | |
504 | ifOptions => sub { @options }, | |
505 | options => sub { nice_options(@options) }, | |
506 | ); | |
507 | # this should be reset once the order has been paid | |
508 | $session{orderPayment} = $order->{id}; | |
509 | ||
510 | page('checkoutcard.tmpl', \%acts); | |
511 | } | |
512 | ||
41b9d8ec TC |
513 | # the real work |
514 | sub purchase { | |
515 | # some basic validation, in case the user switched off javascript | |
516 | my @required = | |
c7adeba6 TC |
517 | (BSE::Custom->required_fields($CGI::Q, $session{custom}), |
518 | qw(cardHolder cardExpiry) ); | |
41b9d8ec TC |
519 | for my $field (@required) { |
520 | defined(param($field)) && length(param($field)) | |
521 | or return checkout("Field $field is required", 1); | |
522 | } | |
523 | defined(param('email')) && param('email') =~ /.\@./ | |
524 | or return checkout("Please enter a valid email address", 1); | |
525 | defined(param('cardNumber')) && param('cardNumber') =~ /^\d+$/ | |
526 | or return checkout("Please enter a credit card number", 1); | |
527 | ||
528 | use Orders; | |
529 | use Order; | |
530 | use OrderItems; | |
531 | use OrderItem; | |
532 | ||
533 | # map some form fields to order field names | |
534 | my %field_map = | |
535 | ( | |
536 | name1 => 'delivFirstName', | |
537 | name2 => 'delivLastName', | |
538 | address => 'delivStreet', | |
539 | city => 'delivSuburb', | |
540 | postcode => 'delivPostCode', | |
541 | state => 'delivState', | |
542 | country => 'delivCountry', | |
543 | email => 'emailAddress', | |
544 | cardHolder => 'ccName', | |
545 | cardType => 'ccType', | |
546 | ); | |
547 | # paranoia, don't store these | |
548 | my %nostore = | |
549 | ( | |
550 | cardNumber => 1, | |
551 | cardExpiry => 1, | |
552 | ); | |
553 | my %order; | |
554 | my @cart = @{$session{cart}}; | |
555 | @cart or return show_cart('You have no items in your shopping cart'); | |
556 | ||
557 | # so we can quickly check for columns | |
558 | my @columns = Order->columns; | |
559 | my %columns; | |
560 | @columns{@columns} = @columns; | |
561 | ||
562 | for my $field (param()) { | |
563 | $order{$field_map{$field} || $field} = param($field) | |
564 | unless $nostore{$field}; | |
565 | } | |
566 | ||
567 | my $ccNumber = param('cardNumber'); | |
568 | my $ccExpiry = param('cardExpiry'); | |
569 | ||
570 | use Digest::MD5 'md5_hex'; | |
571 | $ccNumber =~ tr/0-9//cd; | |
572 | $order{ccNumberHash} = md5_hex($ccNumber); | |
573 | $order{ccExpiryHash} = md5_hex($ccExpiry); | |
574 | ||
575 | # work out totals | |
576 | $order{total} = 0; | |
577 | $order{gst} = 0; | |
578 | $order{wholesale} = 0; | |
579 | my @products; | |
580 | my $today = epoch_to_sql(time); | |
581 | for my $item (@cart) { | |
582 | my $product = Products->getByPkey($item->{productId}); | |
583 | # double check that it's still a valid product | |
584 | if (!$product) { | |
585 | return show_cart("Product $item->{productId} not found"); | |
586 | } | |
587 | elsif ($product->{release} gt $today || $product->{expire} lt $today | |
588 | || !$product->{listed}) { | |
589 | return show_cart("Sorry, '$product->{title}' is no longer available"); | |
590 | } | |
591 | push(@products, $product); # used in page rendering | |
592 | @$item{qw/price wholesalePrice gst/} = | |
593 | @$product{qw/retailPrice wholesalePrice gst/}; | |
594 | $order{total} += $item->{price} * $item->{units}; | |
595 | $order{wholesale} += $item->{wholesalePrice} * $item->{units}; | |
596 | $order{gst} += $item->{gst} * $item->{units}; | |
597 | } | |
edc5d096 TC |
598 | |
599 | if (need_logon($cfg, \@cart, \@products, \%session)) { | |
600 | refresh_to("$URLBASE/cgi-bin/user.pl?message=". | |
601 | CGI::escape("Some of the products in your cart include downloadable files. Please logon or register before checkout.")); | |
602 | return; | |
603 | } | |
604 | ||
41b9d8ec | 605 | $order{orderDate} = $today; |
c7adeba6 TC |
606 | $order{total} += BSE::Custom->total_extras(\@cart, \@products, |
607 | $session{custom}); | |
608 | ++$session{changed}; | |
41b9d8ec TC |
609 | |
610 | # blank anything else | |
611 | for my $column (@columns) { | |
612 | defined $order{$column} or $order{$column} = ''; | |
613 | } | |
691cde19 TC |
614 | # make sure the user can't set these behind our backs |
615 | $order{filled} = 0; | |
616 | $order{paidFor} = 0; | |
617 | ||
edc5d096 TC |
618 | if ($session{userid}) { |
619 | $order{userId} = $session{userid}; | |
620 | } | |
621 | else { | |
622 | $order{userId} = ''; | |
623 | } | |
624 | ||
691cde19 TC |
625 | # this should be hard to guess |
626 | $order{randomId} = md5_hex(time().rand().{}.$$); | |
41b9d8ec | 627 | |
c7adeba6 TC |
628 | # check if a customizer has anything to do |
629 | eval { | |
630 | BSE::Custom->order_save($CGI::Q, \%order, \@cart, \@products); | |
631 | }; | |
632 | if ($@) { | |
633 | return checkout($@, 1); | |
634 | } | |
635 | ||
41b9d8ec TC |
636 | # load up the database |
637 | my @data = @order{@columns}; | |
638 | shift @data; # lose the dummy id | |
639 | my $order = Orders->add(@data) | |
640 | or die "Cannot add order"; | |
641 | my @items; | |
642 | my @item_cols = OrderItem->columns; | |
643 | for my $row (@cart) { | |
644 | $row->{orderId} = $order->{id}; | |
645 | my @data = @$row{@item_cols}; | |
646 | shift @data; | |
647 | push(@items, OrderItems->add(@data)); | |
648 | } | |
649 | ||
650 | my $item_index = -1; | |
d7c3b6f9 TC |
651 | my @options; |
652 | my $option_index; | |
41b9d8ec TC |
653 | my %acts; |
654 | %acts = | |
655 | ( | |
c7adeba6 TC |
656 | BSE::Custom->purchase_actions(\%acts, \@items, \@products, |
657 | $session{custom}), | |
41b9d8ec | 658 | iterate_items_reset => sub { $item_index = -1; }, |
d7c3b6f9 TC |
659 | iterate_items => |
660 | sub { | |
661 | if (++$item_index < @items) { | |
662 | $option_index = -1; | |
663 | @options = cart_item_opts($items[$item_index], | |
664 | $products[$item_index]); | |
665 | return 1; | |
666 | } | |
667 | return 0; | |
668 | }, | |
41b9d8ec TC |
669 | item=> sub { CGI::escapeHTML($items[$item_index]{$_[0]}); }, |
670 | product => sub { CGI::escapeHTML($products[$item_index]{$_[0]}) }, | |
691cde19 TC |
671 | extended => |
672 | sub { | |
673 | my $what = $_[0] || 'retailPrice'; | |
674 | $items[$item_index]{units} * $items[$item_index]{$what}; | |
675 | }, | |
41b9d8ec TC |
676 | order => sub { CGI::escapeHTML($order->{$_[0]}) }, |
677 | money => | |
678 | sub { | |
679 | my ($func, $args) = split ' ', $_[0], 2; | |
680 | $acts{$func} || return "<: money $_[0] :>"; | |
681 | return sprintf("%.02f", $acts{$func}->($args)/100); | |
682 | }, | |
683 | _format => | |
684 | sub { | |
685 | my ($value, $fmt) = @_; | |
686 | if ($fmt =~ /^m(\d+)/) { | |
687 | return sprintf("%$1s", sprintf("%.2f", $value/100)); | |
688 | } | |
689 | elsif ($fmt =~ /%/) { | |
690 | return sprintf($fmt, $value); | |
691 | } | |
692 | }, | |
d7c3b6f9 TC |
693 | iterate_options_reset => sub { $option_index = -1 }, |
694 | iterate_options => sub { ++$option_index < @options }, | |
695 | option => sub { CGI::escapeHTML($options[$option_index]{$_[0]}) }, | |
696 | ifOptions => sub { @options }, | |
697 | options => sub { nice_options(@options) }, | |
41b9d8ec TC |
698 | ); |
699 | send_order($order, \@items, \@products); | |
700 | $session{cart} = []; # empty the cart | |
701 | page('checkoutfinal.tmpl', \%acts); | |
702 | } | |
703 | ||
704 | # sends the email order confirmation and the PGP encrypted | |
705 | # email to the site owner | |
706 | sub send_order { | |
707 | my ($order, $items, $products) = @_; | |
708 | ||
709 | my %extras = %EXTRA_TAGS; | |
710 | for my $key (keys %extras) { | |
711 | unless (ref $extras{$key}) { | |
712 | my $data = $extras{$key}; | |
713 | $extras{$key} = sub { $data }; | |
714 | } | |
715 | } | |
716 | ||
717 | my $item_index = -1; | |
d7c3b6f9 TC |
718 | my @options; |
719 | my $option_index; | |
41b9d8ec TC |
720 | my %acts; |
721 | %acts = | |
722 | ( | |
723 | %extras, | |
724 | ||
725 | iterate_items_reset => sub { $item_index = -1; }, | |
d7c3b6f9 TC |
726 | iterate_items => |
727 | sub { | |
728 | if (++$item_index < @$items) { | |
729 | $option_index = -1; | |
730 | @options = cart_item_opts($items->[$item_index], | |
731 | $products->[$item_index]); | |
732 | return 1; | |
733 | } | |
734 | return 0; | |
735 | }, | |
41b9d8ec TC |
736 | item=> sub { $items->[$item_index]{$_[0]}; }, |
737 | product => sub { $products->[$item_index]{$_[0]} }, | |
738 | order => sub { $order->{$_[0]} }, | |
739 | extended => | |
740 | sub { | |
741 | $items->[$item_index]{units} * $items->[$item_index]{$_[0]}; | |
742 | }, | |
743 | money => | |
744 | sub { | |
745 | my ($func, $args) = split ' ', $_[0], 2; | |
746 | $acts{$func} || return "<: money $_[0] :>"; | |
747 | return sprintf("%.02f", $acts{$func}->($args)/100); | |
748 | }, | |
749 | _format => | |
750 | sub { | |
751 | my ($value, $fmt) = @_; | |
752 | if ($fmt =~ /^m(\d+)/) { | |
753 | return sprintf("%$1s", sprintf("%.2f", $value/100)); | |
754 | } | |
755 | elsif ($fmt =~ /%/) { | |
756 | return sprintf($fmt, $value); | |
757 | } | |
758 | elsif ($fmt =~ /^\d+$/) { | |
759 | return substr($value . (" " x $fmt), 0, $fmt); | |
760 | } | |
761 | else { | |
762 | return $value; | |
763 | } | |
764 | }, | |
d7c3b6f9 TC |
765 | iterate_options_reset => sub { $option_index = -1 }, |
766 | iterate_options => sub { ++$option_index < @options }, | |
767 | option => sub { CGI::escapeHTML($options[$option_index]{$_[0]}) }, | |
768 | ifOptions => sub { @options }, | |
769 | options => sub { nice_options(@options) }, | |
41b9d8ec TC |
770 | ); |
771 | my $templ = Squirrel::Template->new; | |
772 | ||
a82753e6 | 773 | my $mailer = BSE::Mail->new; |
41b9d8ec TC |
774 | # ok, send some email |
775 | my $confirm = $templ->show_page($TMPLDIR, 'mailconfirm.tmpl', \%acts); | |
776 | if ($SHOP_EMAIL_ORDER) { | |
777 | $acts{cardNumber} = sub { param('cardNumber') }; | |
778 | $acts{cardExpiry} = sub { param('cardExpiry') }; | |
779 | my $ordertext = $templ->show_page($TMPLDIR, 'mailorder.tmpl', \%acts); | |
780 | ||
781 | eval "use $crypto_class"; | |
782 | !$@ or die $@; | |
783 | my $encrypter = $crypto_class->new; | |
784 | ||
785 | # encrypt and sign | |
786 | my %opts = | |
787 | ( | |
788 | sign=> 1, | |
789 | passphrase=> $passphrase, | |
790 | stripwarn=>1, | |
791 | #debug=>1, | |
792 | ); | |
793 | $opts{secretkeyid} = $signing_id if $signing_id; | |
794 | $opts{pgp} = $pgp if $pgp; | |
795 | $opts{gpg} = $gpg if $gpg; | |
796 | $opts{pgpe} = $pgpe if $pgpe; | |
797 | #$opts{home} = '/home/bodyscoop'; | |
798 | my $recip = "$toName $toEmail"; | |
799 | ||
800 | my $crypted = $encrypter->encrypt($recip, $ordertext, %opts ) | |
801 | or die "Cannot encrypt ", $encrypter->error; | |
802 | ||
dec5de5e | 803 | $mailer->send(to=>$toEmail, from=>$from, subject=>'New Order '.$order->{id}, |
a82753e6 | 804 | body=>$crypted) |
33bccea7 | 805 | or print STDERR "Error sending order to admin: ",$mailer->errstr,"\n"; |
41b9d8ec | 806 | } |
a82753e6 TC |
807 | $mailer->send(to=>$order->{emailAddress}, from=>$from, |
808 | subject=>$subject . " " . localtime, | |
809 | body=>$confirm) | |
33bccea7 | 810 | or print STDERR "Error sending order to customer: ",$mailer->errstr,"\n"; |
41b9d8ec TC |
811 | } |
812 | ||
813 | sub page { | |
814 | my ($template, $acts) = @_; | |
815 | print "Content-Type: text/html\n\n"; | |
816 | print Squirrel::Template->new->show_page($TMPLDIR, $template, $acts); | |
817 | } | |
818 | ||
819 | # convert an epoch time to sql format | |
820 | sub epoch_to_sql { | |
821 | use POSIX 'strftime'; | |
822 | my ($time) = @_; | |
823 | ||
824 | return strftime('%Y-%m-%d', localtime $time); | |
825 | } | |
826 | ||
827 | __END__ | |
828 | ||
829 | =head1 NAME | |
830 | ||
831 | shop.pl - implements the shop for BSE | |
832 | ||
833 | =head1 DESCRIPTION | |
834 | ||
835 | shop.pl implements the shop for BSE. | |
836 | ||
837 | =head1 TAGS | |
838 | ||
839 | =head2 Cart page | |
840 | ||
841 | =over 4 | |
842 | ||
843 | =item iterator ... items | |
844 | ||
845 | Iterates over the items in the shopping cart, setting the C<item> tag | |
846 | for each one. | |
847 | ||
848 | =item item I<field> | |
849 | ||
850 | Retreives the given field from the item. This can include product | |
851 | fields for this item. | |
852 | ||
853 | =item index | |
854 | ||
855 | The numeric index of the current item. | |
856 | ||
691cde19 TC |
857 | =item extended [<field>] |
858 | ||
859 | The "extended price", the product of the unit cost and the number of | |
860 | units for the current item in the cart. I<field> defaults to the | |
861 | price of the product. | |
862 | ||
41b9d8ec TC |
863 | =item money I<which> <field> |
864 | ||
865 | Formats the given field as a money value (without a currency symbol.) | |
866 | ||
867 | =item count | |
868 | ||
869 | The number of items in the cart. | |
870 | ||
871 | =back | |
872 | ||
873 | =head2 Checkout tags | |
874 | ||
875 | This has the same tags as the L<Cart page>, and some extras: | |
876 | ||
877 | =over 4 | |
878 | ||
879 | =item total | |
880 | ||
881 | The total cost of all items in the cart. | |
882 | ||
883 | This will need to be formatted as a money value with the C<money> tag. | |
884 | ||
885 | =item message | |
886 | ||
887 | An error message, if a validation error occurred. | |
888 | ||
889 | =item old I<field> | |
890 | ||
891 | The previously entered value for I<field>. This should be used as the | |
892 | value for the various checkout fields, so that if a validation error | |
893 | occurs the user won't need to re-enter values. | |
894 | ||
895 | =back | |
896 | ||
897 | =head2 Completed order | |
898 | ||
899 | These tags are used in the F<checkoutfinal_base.tmpl>. | |
900 | ||
901 | =over 4 | |
902 | ||
903 | =item item I<field> | |
904 | ||
905 | =item product I<field> | |
906 | ||
907 | This is split out for these forms. | |
908 | ||
909 | =item order I<field> | |
910 | ||
911 | Order fields. | |
912 | ||
913 | =back | |
914 | ||
915 | You can also use "|format" at the end of a field to perform some | |
916 | simple formatting. Eg. <:order total |m6:> or <:order id |%06d:>. | |
917 | ||
918 | =over 4 | |
919 | ||
920 | =item m<number> | |
921 | ||
922 | Formats the value as a <number> wide money value. | |
923 | ||
924 | =item %<format> | |
925 | ||
926 | Performs sprintf() formatting on the value. Eg. %06d will format 25 | |
927 | as 000025. | |
928 | ||
929 | =back | |
930 | ||
931 | =head2 Mailed order tags | |
932 | ||
933 | These tags are used in the emails sent to the user to confirm an order | |
934 | and in the encrypted copy sent to the site administrator: | |
935 | ||
936 | =over 4 | |
937 | ||
938 | =item iterate ... items | |
939 | ||
940 | Iterates over the items in the order. | |
941 | ||
942 | =item item I<field> | |
943 | ||
944 | Access to the given field in the order item. | |
945 | ||
946 | =item product I<field> | |
947 | ||
948 | Access to the product field for the current order item. | |
949 | ||
950 | =item order I<field> | |
951 | ||
952 | Access to fields of the order. | |
953 | ||
954 | =item extended I<field> | |
955 | ||
956 | The product of the I<field> in the current item and it's quantity. | |
957 | ||
958 | =item money I<tag> I<parameters> | |
959 | ||
960 | Formats the given field as a money value. | |
961 | ||
962 | =back | |
963 | ||
964 | The mail generation template can use extra formatting specified with | |
965 | '|format': | |
966 | ||
967 | =over 4 | |
968 | ||
969 | =item m<number> | |
970 | ||
971 | Format the value as a I<number> wide money value. | |
972 | ||
973 | =item %<format> | |
974 | ||
975 | Performs sprintf formatting on the value. | |
976 | ||
977 | =item <number> | |
978 | ||
979 | Left justifies the value in a I<number> wide field. | |
980 | ||
981 | =back | |
982 | ||
983 | The order email sent to the site administrator has a couple of extra | |
984 | fields: | |
985 | ||
986 | =over 4 | |
987 | ||
988 | =item cardNumber | |
989 | ||
990 | The credit card number of the user's credit card. | |
991 | ||
992 | =item cardExpiry | |
993 | ||
994 | The entered expiry date for the user's credit card. | |
995 | ||
996 | =back | |
997 | ||
998 | =head2 Order fields | |
999 | ||
691cde19 TC |
1000 | These names can be used with the <: order ... :> tag. |
1001 | ||
1002 | Monetary values should typically be used with <:money order ...:> | |
1003 | ||
41b9d8ec TC |
1004 | =over 4 |
1005 | ||
1006 | =item id | |
1007 | ||
1008 | The order id or order number. | |
1009 | ||
1010 | =item delivFirstName | |
1011 | ||
1012 | =item delivLastName | |
1013 | ||
1014 | =item delivStreet | |
1015 | ||
1016 | =item delivSuburb | |
1017 | ||
1018 | =item delivState | |
1019 | ||
1020 | =item delivPostCode | |
1021 | ||
1022 | =item delivCountry | |
1023 | ||
1024 | Delivery information for the order. | |
1025 | ||
1026 | =item billFirstName | |
1027 | ||
1028 | =item billLastName | |
1029 | ||
1030 | =item billStreet | |
1031 | ||
1032 | =item billSuburb | |
1033 | ||
1034 | =item billState | |
1035 | ||
1036 | =item billPostCode | |
1037 | ||
1038 | =item billCountry | |
1039 | ||
1040 | Billing information for the order. | |
1041 | ||
1042 | =item telephone | |
1043 | ||
1044 | =item facsimile | |
1045 | ||
1046 | =item emailAddress | |
1047 | ||
1048 | Contact information for the order. | |
1049 | ||
1050 | =item total | |
1051 | ||
1052 | Total price of the order. | |
1053 | ||
1054 | =item wholesaleTotal | |
1055 | ||
1056 | Wholesale cost of the total. Your costs, if you entered wholesale | |
1057 | prices for the products. | |
1058 | ||
1059 | =item gst | |
1060 | ||
1061 | GST (in Australia) payable on the order, if you entered GST for the products. | |
1062 | ||
1063 | =item orderDate | |
1064 | ||
1065 | When the order was made. | |
1066 | ||
691cde19 TC |
1067 | =item filled |
1068 | ||
1069 | Whether or not the order has been filled. This can be used with the | |
1070 | order_filled target in shopadmin.pl for tracking filled orders. | |
1071 | ||
1072 | =item whenFilled | |
1073 | ||
1074 | The time and date when the order was filled. | |
1075 | ||
1076 | =item whoFilled | |
1077 | ||
1078 | The user who marked the order as filled. | |
1079 | ||
1080 | =item paidFor | |
1081 | ||
1082 | Whether or not the order has been paid for. This can be used with a | |
1083 | custom purchasing handler to mark the product as paid for. You can | |
1084 | then filter the order list to only display paid for orders. | |
1085 | ||
1086 | =item paymentReceipt | |
1087 | ||
1088 | A custom payment handler can fill this with receipt information. | |
1089 | ||
1090 | =item randomId | |
1091 | ||
1092 | Generated by the prePurchase target, this can be used as a difficult | |
1093 | to guess identifier for orders, when working with custom payment | |
1094 | handlers. | |
1095 | ||
1096 | =item cancelled | |
1097 | ||
1098 | This can be used by a custom payment handler to mark an order as | |
1099 | cancelled if the user starts processing an order without completing | |
1100 | payment. | |
1101 | ||
41b9d8ec TC |
1102 | =back |
1103 | ||
1104 | =head2 Order item fields | |
1105 | ||
1106 | =over 4 | |
1107 | ||
1108 | =item productId | |
1109 | ||
1110 | The product id of this item. | |
1111 | ||
1112 | =item orderId | |
1113 | ||
1114 | The order Id. | |
1115 | ||
1116 | =item units | |
1117 | ||
1118 | The number of units for this item. | |
1119 | ||
1120 | =item price | |
1121 | ||
1122 | The price paid for the product. | |
1123 | ||
1124 | =item wholesalePrice | |
1125 | ||
1126 | The wholesale price for the product. | |
1127 | ||
1128 | =item gst | |
1129 | ||
1130 | The gst for the product. | |
1131 | ||
691cde19 TC |
1132 | =item options |
1133 | ||
1134 | A comma separated list of options specified for this item. These | |
1135 | correspond to the option names in the product. | |
1136 | ||
41b9d8ec TC |
1137 | =back |
1138 | ||
d7c3b6f9 TC |
1139 | =head2 Options |
1140 | ||
1141 | New with 0.10_04 is the facility to set options for each product. | |
1142 | ||
1143 | The cart, checkout and checkoutfinal pages now include the following | |
1144 | tags: | |
1145 | ||
1146 | =over | |
1147 | ||
1148 | =item iterator ... options | |
1149 | ||
1150 | within an item, iterates over the options for this item in the cart. | |
1151 | Sets the item tag. | |
1152 | ||
1153 | =item option field | |
1154 | ||
1155 | Retrieves the given field from the option, possible field names are: | |
1156 | ||
1157 | =over | |
1158 | ||
1159 | =item id | |
1160 | ||
1161 | The type/identifier for this option. eg. msize for a male clothing | |
1162 | size field. | |
1163 | ||
1164 | =item value | |
1165 | ||
1166 | The underlying value of the option, eg. XL. | |
1167 | ||
1168 | =item desc | |
1169 | ||
1170 | The description of the field from the product options hash. If the | |
1171 | description isn't defined this is the same as the id. eg. Size. | |
1172 | ||
1173 | =item label | |
1174 | ||
1175 | The description of the value from the product options hash. | |
1176 | eg. "Extra large". | |
1177 | ||
1178 | =back | |
1179 | ||
1180 | =item ifOptions | |
1181 | ||
1182 | A conditional tag, true if the current cart item has any options. | |
1183 | ||
1184 | =item options | |
1185 | ||
1186 | A simple rendering of the options as a parenthesized comma-separated | |
1187 | list. | |
1188 | ||
1189 | =back | |
1190 | ||
41b9d8ec | 1191 | =cut |