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