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