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