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