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