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