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