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