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