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