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