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