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