71513: Card tokenization [v3 test tweak]
[freeside.git] / FS / FS / quotation.pm
1 package FS::quotation;
2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
3
4 use strict;
5 use Tie::RefHash;
6 use FS::CurrentUser;
7 use FS::UID qw( dbh );
8 use FS::Maketext qw( emt );
9 use FS::Record qw( qsearch qsearchs );
10 use FS::Conf;
11 use FS::cust_main;
12 use FS::cust_pkg;
13 use FS::prospect_main;
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
16 use FS::type_pkgs;
17
18 =head1 NAME
19
20 FS::quotation - Object methods for quotation records
21
22 =head1 SYNOPSIS
23
24   use FS::quotation;
25
26   $record = new FS::quotation \%hash;
27   $record = new FS::quotation { 'column' => 'value' };
28
29   $error = $record->insert;
30
31   $error = $new_record->replace($old_record);
32
33   $error = $record->delete;
34
35   $error = $record->check;
36
37 =head1 DESCRIPTION
38
39 An FS::quotation object represents a quotation.  FS::quotation inherits from
40 FS::Record.  The following fields are currently supported:
41
42 =over 4
43
44 =item quotationnum
45
46 primary key
47
48 =item prospectnum
49
50 prospectnum
51
52 =item custnum
53
54 custnum
55
56 =item _date
57
58 _date
59
60 =item disabled
61
62 disabled
63
64 =item usernum
65
66 usernum
67
68 =item close_date
69
70 projected date when the quotation will be closed
71
72 =item confidence
73
74 projected confidence (expressed as integer) that quotation will close
75
76 =back
77
78 =head1 METHODS
79
80 =over 4
81
82 =item new HASHREF
83
84 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
85
86 Note that this stores the hash reference, not a distinct copy of the hash it
87 points to.  You can ask the object for a copy with the I<hash> method.
88
89 =cut
90
91 sub table { 'quotation'; }
92 sub notice_name { 'Quotation'; }
93 sub template_conf { 'quotation_'; }
94
95 =item insert
96
97 Adds this record to the database.  If there is an error, returns the error,
98 otherwise returns false.
99
100 =item delete
101
102 Delete this record from the database.
103
104 =item replace OLD_RECORD
105
106 Replaces the OLD_RECORD with this one in the database.  If there is an error,
107 returns the error, otherwise returns false.
108
109 =item check
110
111 Checks all fields to make sure this is a valid quotation.  If there is
112 an error, returns the error, otherwise returns false.  Called by the insert
113 and replace methods.
114
115 =cut
116
117 sub check {
118   my $self = shift;
119
120   my $error = 
121     $self->ut_numbern('quotationnum')
122     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
123     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
124     || $self->ut_numbern('_date')
125     || $self->ut_enum('disabled', [ '', 'Y' ])
126     || $self->ut_numbern('usernum')
127     || $self->ut_numbern('close_date')
128     || $self->ut_numbern('confidence')
129   ;
130   return $error if $error;
131
132   $self->_date(time) unless $self->_date;
133
134   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
135
136   return 'confidence must be an integer between 1 and 100'
137     if length($self->confidence) && (($self->confidence < 1) || ($self->confidence > 100));
138
139   return 'prospectnum or custnum must be specified'
140     if ! $self->prospectnum
141     && ! $self->custnum;
142
143   $self->SUPER::check;
144 }
145
146 =item prospect_main
147
148 =cut
149
150 sub prospect_main {
151   my $self = shift;
152   qsearchs('prospect_main', { 'prospectnum' => $self->prospectnum } );
153 }
154
155 =item cust_main
156
157 =cut
158
159 sub cust_main {
160   my $self = shift;
161   qsearchs('cust_main', { 'custnum' => $self->custnum } );
162 }
163
164 =item cust_bill_pkg
165
166 =cut
167
168 sub cust_bill_pkg { #actually quotation_pkg objects
169   my $self = shift;
170   qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
171 }
172
173 =item total_setup
174
175 =cut
176
177 sub total_setup {
178   my $self = shift;
179   $self->_total('setup');
180 }
181
182 =item total_recur [ FREQ ]
183
184 =cut
185
186 sub total_recur {
187   my $self = shift;
188 #=item total_recur [ FREQ ]
189   #my $freq = @_ ? shift : '';
190   $self->_total('recur');
191 }
192
193 sub _total {
194   my( $self, $method ) = @_;
195
196   my $total = 0;
197   $total += $_->$method() for $self->cust_bill_pkg;
198   sprintf('%.2f', $total);
199
200 }
201
202 sub email {
203   my $self = shift;
204   my $opt = shift || {};
205   if ($opt and !ref($opt)) {
206     die ref($self). '->email called with positional parameters';
207   }
208
209   my $conf = $self->conf;
210
211   my $from = delete $opt->{from};
212
213   # this is where we set the From: address
214   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
215         ||  $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
216   $self->SUPER::email( {
217     'from' => $from,
218     %$opt,
219   });
220
221 }
222
223 sub email_subject {
224   my $self = shift;
225
226   my $subject =
227     $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
228       || 'Quotation';
229
230   #my $cust_main = $self->cust_main;
231   #my $name = $cust_main->name;
232   #my $name_short = $cust_main->name_short;
233   #my $invoice_number = $self->invnum;
234   #my $invoice_date = $self->_date_pretty;
235
236   eval qq("$subject");
237 }
238
239 =item cust_or_prosect
240
241 =cut
242
243 sub cust_or_prospect {
244   my $self = shift;
245   $self->custnum ? $self->cust_main : $self->prospect_main;
246 }
247
248 =item cust_or_prospect_label_link P
249
250 HTML links to either the customer or prospect.
251
252 Returns a list consisting of two elements.  The first is a text label for the
253 link, and the second is the URL.
254
255 =cut
256
257 sub cust_or_prospect_label_link {
258   my( $self, $p ) = @_;
259
260   if ( my $custnum = $self->custnum ) {
261     my $display_custnum = $self->cust_main->display_custnum;
262     my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
263                    ? '#quotations'
264                    : ';show=quotations';
265     (
266       emt("View this customer (#[_1])",$display_custnum) =>
267         "${p}view/cust_main.cgi?custnum=$custnum$target"
268     );
269   } elsif ( my $prospectnum = $self->prospectnum ) {
270     (
271       emt("View this prospect (#[_1])",$prospectnum) =>
272         "${p}view/prospect_main.html?$prospectnum"
273     );
274   } else { #die?
275     ( '', '' );
276   }
277
278 }
279
280 sub _items_tax {
281   ();
282 }
283
284 sub _items_nontax {
285   shift->cust_bill_pkg;
286 }
287
288 sub _items_total {
289   my $self = shift;
290   $self->quotationnum =~ /^(\d+)$/ or return ();
291
292   my @items;
293
294   # show taxes in here also; the setup/recurring breakdown is different
295   # from what Template_Mixin expects
296   my @setup_tax = qsearch({
297       select      => 'itemdesc, SUM(setup_amount) as setup_amount',
298       table       => 'quotation_pkg_tax',
299       addl_from   => ' JOIN quotation_pkg USING (quotationpkgnum) ',
300       extra_sql   => ' WHERE quotationnum = '.$1,
301       order_by    => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
302                      ' ORDER BY itemdesc',
303   });
304   # recurs need to be grouped by frequency, and to have a pkgpart
305   my @recur_tax = qsearch({
306       select      => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
307       table       => 'quotation_pkg_tax',
308       addl_from   => ' JOIN quotation_pkg USING (quotationpkgnum)'.
309                      ' JOIN part_pkg USING (pkgpart)',
310       extra_sql   => ' WHERE quotationnum = '.$1,
311       order_by    => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
312                      ' ORDER BY freq, itemdesc',
313   });
314
315   my $total_setup = $self->total_setup;
316   my $total_recur = $self->total_recur;
317   my $setup_show = $total_setup > 0 ? 1 : 0;
318   my $recur_show = $total_recur > 0 ? 1 : 0;
319   unless ($setup_show && $recur_show) {
320     foreach my $quotation_pkg ($self->quotation_pkg) {
321       $setup_show = 1 if !$setup_show and $quotation_pkg->setup_show_zero;
322       $recur_show = 1 if !$recur_show and $quotation_pkg->recur_show_zero;
323       last if $setup_show && $recur_show;
324     }
325   }
326
327   foreach my $pkg_tax (@setup_tax) {
328     if ($pkg_tax->setup_amount > 0) {
329       $total_setup += $pkg_tax->setup_amount;
330       push @items, {
331         'total_item'    => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
332         'total_amount'  => $pkg_tax->setup_amount,
333       };
334     }
335   }
336
337   if ( $setup_show ) {
338     push @items, {
339       'total_item'   => $self->mt( $recur_show ? 'Total Setup' : 'Total' ),
340       'total_amount' => sprintf('%.2f',$total_setup),
341       'break_after'  => ( scalar(@recur_tax) ? 1 : 0 )
342     };
343   }
344
345   #could/should add up the different recurring frequencies on lines of their own
346   # but this will cover the 95% cases for now
347   # label these with the frequency
348   foreach my $pkg_tax (@recur_tax) {
349     if ($pkg_tax->recur_amount > 0) {
350       $total_recur += $pkg_tax->recur_amount;
351       # an arbitrary part_pkg, but with the right frequency
352       # XXX localization
353       my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
354       push @items, {
355         'total_item'    => $pkg_tax->itemdesc . ' (' .  $part_pkg->freq_pretty . ')',
356         'total_amount'  => $pkg_tax->recur_amount,
357       };
358     }
359   }
360
361   if ( $recur_show ) {
362     push @items, {
363       'total_item'   => $self->mt('Total Recurring'),
364       'total_amount' => sprintf('%.2f',$total_recur),
365       'break_after'  => 1,
366     };
367     # show 'first payment' line (setup + recur) if there are no prorated 
368     # packages included
369     my $disable_total = 0;
370     foreach my $quotation_pkg ($self->quotation_pkg) {
371       my $part_pkg = $quotation_pkg->part_pkg;
372       if (    $part_pkg->plan =~ /^(prorate|torrus|agent$)/
373            || $part_pkg->option('recur_method') eq 'prorate'
374            || ( $part_pkg->option('sync_bill_date')
375                   && $self->custnum
376                   && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
377               )
378       ) {
379         $disable_total = 1;
380         last;
381       }
382     }
383     if (!$disable_total) {
384       push @items, {
385         'total_item'   => $self->mt('First payment'),
386         'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
387         'break_after'  => 1,
388       };
389     }
390   }
391
392   return @items;
393
394 }
395
396 =item enable_previous
397
398 =cut
399
400 sub enable_previous { 0 }
401
402 =item convert_cust_main [ PARAMS ]
403
404 If this quotation already belongs to a customer, then returns that customer, as
405 an FS::cust_main object.
406
407 Otherwise, creates a new customer (FS::cust_main object and record, and
408 associated) based on this quotation's prospect, then orders this quotation's
409 packages as real packages for the customer.
410
411 If there is an error, returns an error message, otherwise, returns the
412 newly-created FS::cust_main object.
413
414 Accepts the same params as L</order>.
415
416 =cut
417
418 sub convert_cust_main {
419   my $self = shift;
420   my $params = shift || {};
421
422   my $cust_main = $self->cust_main;
423   return $cust_main if $cust_main; #already converted, don't again
424
425   my $oldAutoCommit = $FS::UID::AutoCommit;
426   local $FS::UID::AutoCommit = 0;
427   my $dbh = dbh;
428
429   $cust_main = $self->prospect_main->convert_cust_main;
430   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
431     $dbh->rollback if $oldAutoCommit;
432     return $cust_main;
433   }
434
435   $self->prospectnum('');
436   $self->custnum( $cust_main->custnum );
437   my $error = $self->replace || $self->order(undef,$params);
438   if ( $error ) {
439     $dbh->rollback if $oldAutoCommit;
440     return $error;
441   }
442
443   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
444
445   $cust_main;
446
447 }
448
449 =item order [ HASHREF ] [ PARAMS ]
450
451 This method is for use with quotations which are already associated with a customer.
452
453 Orders this quotation's packages as real packages for the customer.
454
455 If there is an error, returns an error message, otherwise returns false.
456
457 If HASHREF is passed, it will be filled with a hash mapping the 
458 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
459 as ordered.
460
461 If PARAMS hashref is passed, the following params are accepted:
462
463 onhold - if true, suspends newly ordered packages
464
465 =cut
466
467 sub order {
468   my $self = shift;
469   my $pkgnum_map = shift || {};
470   my $params = shift || {};
471   my $details_map = {};
472
473   tie my %all_cust_pkg, 'Tie::RefHash';
474   foreach my $quotation_pkg ($self->quotation_pkg) {
475     my $cust_pkg = FS::cust_pkg->new;
476     $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
477
478     # details will be copied below, after package is ordered
479     $details_map->{ $quotation_pkg->quotationpkgnum } = [ 
480       map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
481     ];
482
483     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
484       $cust_pkg->set( $_, $quotation_pkg->get($_) );
485     }
486
487     # currently only one discount each
488     my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
489     if ( $pkg_discount ) {
490       $cust_pkg->set('discountnum', $pkg_discount->discountnum);
491     }
492
493     $all_cust_pkg{$cust_pkg} = []; # no services
494   }
495
496   local $SIG{HUP} = 'IGNORE';
497   local $SIG{INT} = 'IGNORE';
498   local $SIG{QUIT} = 'IGNORE';
499   local $SIG{TERM} = 'IGNORE';
500   local $SIG{TSTP} = 'IGNORE';
501   local $SIG{PIPE} = 'IGNORE';
502
503   my $oldAutoCommit = $FS::UID::AutoCommit;
504   local $FS::UID::AutoCommit = 0;
505   my $dbh = dbh;
506
507   my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
508   
509   unless ($error) {
510     # copy details (copy_on_order filtering handled above)
511     foreach my $quotationpkgnum (keys %$details_map) {
512       next unless @{$details_map->{$quotationpkgnum}};
513       $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
514         'I',
515         @{$details_map->{$quotationpkgnum}}
516       );
517       last if $error;
518     }
519   }
520
521   if ($$params{'onhold'}) {
522     foreach my $quotationpkgnum (keys %$pkgnum_map) {
523       last if $error;
524       $error = $pkgnum_map->{$quotationpkgnum}->suspend();
525     }
526   }
527
528   if ($error) {
529     $dbh->rollback if $oldAutoCommit;
530     return $error;
531   }
532
533   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
534
535   foreach my $quotationpkgnum (keys %$pkgnum_map) {
536     # convert the objects to just pkgnums
537     my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
538     $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
539   }
540
541   ''; #no error
542
543 }
544
545 =item quotation_pkg
546
547 =cut
548
549 sub quotation_pkg {
550   my $self = shift;
551   qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
552 }
553
554 =item charge
555
556 One-time charges, like FS::cust_main::charge()
557
558 =cut
559
560 #super false laziness w/cust_main::charge
561 sub charge {
562   my $self = shift;
563   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
564   my ( $pkg, $comment, $additional );
565   my ( $setuptax, $taxclass );   #internal taxes
566   my ( $taxproduct, $override ); #vendor (CCH) taxes
567   my $no_auto = '';
568   my $cust_pkg_ref = '';
569   my ( $bill_now, $invoice_terms ) = ( 0, '' );
570   my $locationnum;
571   if ( ref( $_[0] ) ) {
572     $amount     = $_[0]->{amount};
573     $setup_cost = $_[0]->{setup_cost};
574     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
575     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
576     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
577     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
578     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
579                                            : '$'. sprintf("%.2f",$amount);
580     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
581     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
582     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
583     $additional = $_[0]->{additional} || [];
584     $taxproduct = $_[0]->{taxproductnum};
585     $override   = { '' => $_[0]->{tax_override} };
586     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
587     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
588     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
589     $locationnum = $_[0]->{locationnum};
590   } else {
591     $amount     = shift;
592     $setup_cost = '';
593     $quantity   = 1;
594     $start_date = '';
595     $pkg        = @_ ? shift : 'One-time charge';
596     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
597     $setuptax   = '';
598     $taxclass   = @_ ? shift : '';
599     $additional = [];
600   }
601
602   local $SIG{HUP} = 'IGNORE';
603   local $SIG{INT} = 'IGNORE';
604   local $SIG{QUIT} = 'IGNORE';
605   local $SIG{TERM} = 'IGNORE';
606   local $SIG{TSTP} = 'IGNORE';
607   local $SIG{PIPE} = 'IGNORE';
608
609   my $oldAutoCommit = $FS::UID::AutoCommit;
610   local $FS::UID::AutoCommit = 0;
611   my $dbh = dbh;
612
613   my $part_pkg = new FS::part_pkg ( {
614     'pkg'           => $pkg,
615     'comment'       => $comment,
616     'plan'          => 'flat',
617     'freq'          => 0,
618     'disabled'      => 'Y',
619     'classnum'      => ( $classnum ? $classnum : '' ),
620     'setuptax'      => $setuptax,
621     'taxclass'      => $taxclass,
622     'taxproductnum' => $taxproduct,
623     'setup_cost'    => $setup_cost,
624   } );
625
626   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
627                         ( 0 .. @$additional - 1 )
628                   ),
629                   'additional_count' => scalar(@$additional),
630                   'setup_fee' => $amount,
631                 );
632
633   my $error = $part_pkg->insert( options       => \%options,
634                                  tax_overrides => $override,
635                                );
636   if ( $error ) {
637     $dbh->rollback if $oldAutoCommit;
638     return $error;
639   }
640
641   my $pkgpart = $part_pkg->pkgpart;
642
643   #DIFF
644   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
645
646   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
647     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
648     $error = $type_pkgs->insert;
649     if ( $error ) {
650       $dbh->rollback if $oldAutoCommit;
651       return $error;
652     }
653   }
654
655   #except for DIFF, eveything above is idential to cust_main version
656   #but below is our own thing pretty much (adding a quotation package instead
657   # of ordering a customer package, no "bill now")
658
659   my $quotation_pkg = new FS::quotation_pkg ( {
660     'quotationnum'  => $self->quotationnum,
661     'pkgpart'       => $pkgpart,
662     'quantity'      => $quantity,
663     #'start_date' => $start_date,
664     #'no_auto'    => $no_auto,
665     'locationnum'=> $locationnum,
666   } );
667
668   $error = $quotation_pkg->insert;
669   if ( $error ) {
670     $dbh->rollback if $oldAutoCommit;
671     return $error;
672   #} elsif ( $cust_pkg_ref ) {
673   #  ${$cust_pkg_ref} = $cust_pkg;
674   }
675
676   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
677   return '';
678
679 }
680
681 =item disable
682
683 Disables this quotation (sets disabled to Y, which hides the quotation on
684 prospects and customers).
685
686 If there is an error, returns an error message, otherwise returns false.
687
688 =cut
689
690 sub disable {
691   my $self = shift;
692   $self->disabled('Y');
693   $self->replace();
694 }
695
696 =item enable
697
698 Enables this quotation.
699
700 If there is an error, returns an error message, otherwise returns false.
701
702 =cut
703
704 sub enable {
705   my $self = shift;
706   $self->disabled('');
707   $self->replace();
708 }
709
710 =item estimate
711
712 Calculates current prices for all items on this quotation, including 
713 discounts and taxes, and updates the quotation_pkg records accordingly.
714
715 =cut
716
717 sub estimate {
718   my $self = shift;
719   my $conf = FS::Conf->new;
720
721   my $dbh = dbh;
722   my $oldAutoCommit = $FS::UID::AutoCommit;
723   local $FS::UID::AutoCommit = 0;
724
725   # bring individual items up to date (set setup/recur and discounts)
726   my @quotation_pkg = $self->quotation_pkg;
727   foreach my $pkg (@quotation_pkg) {
728     my $error = $pkg->estimate;
729     if ($error) {
730       $dbh->rollback if $oldAutoCommit;
731       die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
732     }
733
734     # delete old tax records
735     foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
736       $error = $quotation_pkg_tax->delete;
737       if ( $error ) {
738         $dbh->rollback if $oldAutoCommit;
739         die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
740       }
741     }
742   }
743
744   # annoyingly duplicates handle_taxes--fix this in 4.x 
745   if ( $conf->exists('enable_taxproducts') ) {
746     warn "can't calculate external taxes for quotations yet\n";
747     # then we're done
748     return;
749   }
750
751   my %taxnum_exemptions; # for monthly exemptions; as yet unused
752
753   foreach my $pkg (@quotation_pkg) {
754     my $location = $pkg->cust_location;
755
756     my $part_item = $pkg->part_pkg; # we don't have fees on these yet
757     my @loc_keys = qw( district city county state country);
758     my %taxhash = map { $_ => $location->$_ } @loc_keys;
759     $taxhash{'taxclass'} = $part_item->taxclass;
760     my @taxes;
761     my %taxhash_elim = %taxhash;
762     my @elim = qw( district city county state );
763     do {
764       @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
765       if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
766         #then try a match without taxclass
767         my %no_taxclass = %taxhash_elim;
768         $no_taxclass{ 'taxclass' } = '';
769         @taxes = qsearch( 'cust_main_county', \%no_taxclass );
770       }
771     
772       $taxhash_elim{ shift(@elim) } = '';
773     } while ( !scalar(@taxes) && scalar(@elim) );
774
775     foreach my $tax_def (@taxes) {
776       my $taxnum = $tax_def->taxnum;
777       $taxnum_exemptions{$taxnum} ||= [];
778
779       # XXX do some kind of equivalent to set_exemptions here
780       # but for now just declare that there are no exemptions,
781       # and then hack the taxable amounts if the package def
782       # excludes setup/recur
783       $pkg->set('cust_tax_exempt_pkg', []);
784
785       if ( $part_item->setuptax or $tax_def->setuptax ) {
786         $pkg->set('unitsetup', 0);
787       }
788       if ( $part_item->recurtax or $tax_def->recurtax ) {
789         $pkg->set('unitrecur', 0);
790       }
791
792       my %taxline;
793       foreach my $pass (qw(first recur)) {
794         if ($pass eq 'recur') {
795           $pkg->set('unitsetup', 0);
796         }
797
798         my $taxline = $tax_def->taxline(
799           [ $pkg ],
800           exemptions => $taxnum_exemptions{$taxnum}
801         );
802         if ($taxline and !ref($taxline)) {
803           $dbh->rollback if $oldAutoCommit;
804           die "error calculating '".$tax_def->taxname .
805               "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
806         }
807         $taxline{$pass} = $taxline;
808       }
809
810       my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
811           quotationpkgnum => $pkg->quotationpkgnum,
812           itemdesc        => ($tax_def->taxname || 'Tax'),
813           taxnum          => $taxnum,
814           taxtype         => ref($tax_def),
815       });
816       my $setup_amount = 0;
817       my $recur_amount = 0;
818       if ($taxline{first}) {
819         $setup_amount = $taxline{first}->setup; # "first cycle", not setup
820       }
821       if ($taxline{recur}) {
822         $recur_amount = $taxline{recur}->setup;
823         $setup_amount -= $recur_amount; # to get the actual setup amount
824       }
825       if ( $recur_amount > 0 or $setup_amount > 0 ) {
826         $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
827         $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
828
829         my $error = $quotation_pkg_tax->insert;
830         if ($error) {
831           $dbh->rollback if $oldAutoCommit;
832           die "error recording '".$tax_def->taxname .
833               "' for pkgpart '".$pkg->pkgpart."': $error\n";
834         } # if $error
835       } # else there are no non-zero taxes; continue
836     } # foreach $tax_def
837   } # foreach $pkg
838
839   $dbh->commit if $oldAutoCommit;
840   '';
841 }
842
843 =back
844
845 =head1 CLASS METHODS
846
847 =over 4
848
849
850 =item search_sql_where HASHREF
851
852 Class method which returns an SQL WHERE fragment to search for parameters
853 specified in HASHREF.  Valid parameters are
854
855 =over 4
856
857 =item _date
858
859 List reference of start date, end date, as UNIX timestamps.
860
861 =item invnum_min
862
863 =item invnum_max
864
865 =item agentnum
866
867 =item charged
868
869 List reference of charged limits (exclusive).
870
871 =item owed
872
873 List reference of charged limits (exclusive).
874
875 =item open
876
877 flag, return open invoices only
878
879 =item net
880
881 flag, return net invoices only
882
883 =item days
884
885 =item newest_percust
886
887 =back
888
889 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
890
891 =cut
892
893 sub search_sql_where {
894   my($class, $param) = @_;
895   #if ( $DEBUG ) {
896   #  warn "$me search_sql_where called with params: \n".
897   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
898   #}
899
900   my @search = ();
901
902   #agentnum
903   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
904     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
905   }
906
907 #  #refnum
908 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
909 #    push @search, "cust_main.refnum = $1";
910 #  }
911
912   #prospectnum
913   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
914     push @search, "quotation.prospectnum = $1";
915   }
916
917   #custnum
918   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
919     push @search, "cust_bill.custnum = $1";
920   }
921
922   #_date
923   if ( $param->{_date} ) {
924     my($beginning, $ending) = @{$param->{_date}};
925
926     push @search, "quotation._date >= $beginning",
927                   "quotation._date <  $ending";
928   }
929
930   #quotationnum
931   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
932     push @search, "quotation.quotationnum >= $1";
933   }
934   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
935     push @search, "quotation.quotationnum <= $1";
936   }
937
938 #  #charged
939 #  if ( $param->{charged} ) {
940 #    my @charged = ref($param->{charged})
941 #                    ? @{ $param->{charged} }
942 #                    : ($param->{charged});
943 #
944 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
945 #                      @charged;
946 #  }
947
948   my $owed_sql = FS::cust_bill->owed_sql;
949
950   #days
951   push @search, "quotation._date < ". (time-86400*$param->{'days'})
952     if $param->{'days'};
953
954   #agent virtualization
955   my $curuser = $FS::CurrentUser::CurrentUser;
956   #false laziness w/search/quotation.html
957   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
958                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
959                ' )    ';
960
961   join(' AND ', @search );
962
963 }
964
965 =item _items_pkg
966
967 Return line item hashes for each package on this quotation. Differs from the
968 base L<FS::Template_Mixin> version in that it recalculates each quoted package
969 first, and doesn't implement the "condensed" option.
970
971 =cut
972
973 sub _items_pkg {
974   my ($self, %options) = @_;
975   $self->estimate;
976   # run it through the Template_Mixin engine
977   return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
978 }
979
980 =back
981
982 =head1 BUGS
983
984 =head1 SEE ALSO
985
986 L<FS::Record>, schema.html from the base documentation.
987
988 =cut
989
990 1;
991