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