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