discounts on one-time charges< RT#75448
[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     $error = $fake_self->order(\%pkgnum_of);
702     die "$error (simulating package order)\n" if $error;
703
704     my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
705
706     # simulate the first bill
707     my %bill_opt = (
708       'estimate'        => 1,
709       'pkg_list'        => \@new_pkgs,
710       'time'            => time, # an option to adjust this?
711       'return_bill'     => $return_bill[0],
712       'no_usage_reset'  => 1,
713     );
714     $error = $cust_main->bill(%bill_opt);
715     die "$error (simulating initial billing)\n" if $error;
716
717     # pick dates for future bills
718     my %next_bill_pkgs;
719     foreach (@new_pkgs) {
720       my $bill = $_->get('bill');
721       next if !$bill;
722       push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
723     }
724
725     my $i = 1;
726     foreach my $next_bill (keys %next_bill_pkgs) {
727       $bill_opt{'time'} = $next_bill;
728       $bill_opt{'return_bill'} = $return_bill[$i] = [];
729       $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
730       $error = $cust_main->bill(%bill_opt);
731       die "$error (simulating recurring billing cycle $i)\n" if $error;
732       $i++;
733     }
734
735     $temp_dbh->rollback;
736   };
737   return $@ if $@;
738   ###### END TRANSACTION ######
739   my %quotationpkgnum_of = reverse %pkgnum_of;
740
741   if ($DEBUG) {
742     warn "pkgnums:\n".Dumper(\%pkgnum_of);
743     warn Dumper(\@return_bill);
744   }
745
746   # Careful: none of the foreign keys in here are correct outside the sandbox.
747   # We have a translation table for pkgnums; all others are total lies.
748
749   my %quotation_pkg; # quotationpkgnum => quotation_pkg
750   foreach my $qp ($self->quotation_pkg) {
751     $quotation_pkg{$qp->quotationpkgnum} = $qp;
752     $qp->set($_, 0) foreach qw(unitsetup unitrecur);
753     $qp->set('freq', '');
754     # flush old tax records
755     foreach ($qp->quotation_pkg_tax) {
756       $error = $_->delete;
757       return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")" 
758         if $error;
759     }
760   }
761
762   my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
763   my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
764
765   for (my $i = 0; $i < scalar(@return_bill); $i++) {
766     my $this_bill = $return_bill[$i]->[0];
767     if (!$this_bill) {
768       warn "$me billing cycle $i produced no invoice\n";
769       next;
770     }
771
772     my @nonpkg_lines;
773     my %cust_bill_pkg;
774     foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
775       my $pkgnum = $cust_bill_pkg->pkgnum;
776       $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
777       if ( !$pkgnum ) {
778         # taxes/fees; come back to it
779         push @nonpkg_lines, $cust_bill_pkg;
780         next;
781       }
782       my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
783       my $qp = $quotation_pkg{$quotationpkgnum};
784       if (!$qp) {
785         # XXX supplemental packages could do this (they have separate pkgnums)
786         # handle that special case at some point
787         warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
788         next;
789       }
790       if ( $i == 0 ) {
791         # then this is the first (setup) invoice
792         $qp->set('start_date', $cust_bill_pkg->sdate);
793         $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
794         # pkgpart_override is a possibility
795       } else {
796         # recurring invoice (should be only one of these per package, though
797         # it may have multiple lineitems with the same pkgnum)
798         $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
799       }
800
801       # discounts
802       if ( $cust_bill_pkg->get('discounts') ) {
803         # discount records are generated as (setup, recur).
804         # well, not always, sometimes it's just (recur), but fixing this
805         # is horribly invasive.
806         my $discount = $cust_bill_pkg->get('discounts')->[0];
807
808         if ( $discount ) {
809           # find the quotation_pkg_discount record for this billing pass...
810           my $setuprecur = $i ? 'recur' : 'setup';
811           my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
812                 ||= qsearchs('quotation_pkg_discount', {
813                     'quotationpkgnum' => $quotationpkgnum,
814                     'setuprecur'      => $setuprecur,
815                     });
816
817           if (!$qpd) { #can't happen
818             warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
819           }
820           if ($qpd) {
821             $qpd->set('amount', $discount->amount);
822           }
823         }
824       } # end of discount stuff
825
826     }
827
828     # create tax records
829     foreach my $cust_bill_pkg (@nonpkg_lines) {
830
831       my $itemdesc = $cust_bill_pkg->itemdesc;
832
833       if ($cust_bill_pkg->feepart) {
834         warn "$me simulated bill included a non-package fee (feepart ".
835           $cust_bill_pkg->feepart.")\n";
836         next;
837       }
838       my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
839                   $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
840                   [];
841       # breadth-first unrolled recursion:
842       # take each tax link and any tax-on-tax descendants, and merge them 
843       # into a single quotation_pkg_tax record for each pkgnum/taxname 
844       # combination
845       while (my $tax_link = shift @$links) {
846         my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
847           or die "$me unable to resolve tax link\n";
848         if ($target->pkgnum) {
849           my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
850           # create this if there isn't one yet
851           my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
852             FS::quotation_pkg_tax->new({
853               quotationpkgnum => $quotationpkgnum,
854               itemdesc        => $itemdesc,
855               setup_amount    => 0,
856               recur_amount    => 0,
857             });
858           if ( $i == 0 ) { # first invoice
859             $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
860           } else { # subsequent invoices
861             # this isn't perfectly accurate, but that's why it's an estimate
862             $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
863             $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
864             $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
865           }
866         } elsif ($target->feepart) {
867           # do nothing; we already warned for the fee itself
868         } else {
869           # tax on tax: the tax target is another tax item.
870           # since this is an estimate, I'm just going to assign it to the 
871           # first of the underlying packages. (RT#5243 is why we can't have
872           # nice things.)
873           my $sublinks = $target->cust_bill_pkg_tax_rate_location;
874           if ($sublinks and $sublinks->[0]) {
875             $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
876             push @$links, $tax_link; #try again
877           } else {
878             warn "$me unable to assign tax on tax; ignoring\n";
879           }
880         }
881       } # while my $tax_link
882
883     } # foreach my $cust_bill_pkg
884   }
885   foreach my $quotation_pkg (values %quotation_pkg) {
886     $error = $quotation_pkg->replace;
887     return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
888       if $error;
889   }
890   foreach (values %quotation_pkg_discount) {
891     # { setup => one, recur => another }
892     foreach my $quotation_pkg_discount (values %$_) {
893       $error = $quotation_pkg_discount->replace;
894       return "$error (recording estimated discount)"
895         if $error;
896     }
897   }
898   foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
899     $error = $quotation_pkg_tax->insert;
900     return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
901     if $error;
902   }
903   return;
904 }
905
906 =back
907
908 =head1 CLASS METHODS
909
910 =over 4
911
912
913 =item search_sql_where HASHREF
914
915 Class method which returns an SQL WHERE fragment to search for parameters
916 specified in HASHREF.  Valid parameters are
917
918 =over 4
919
920 =item _date
921
922 List reference of start date, end date, as UNIX timestamps.
923
924 =item invnum_min
925
926 =item invnum_max
927
928 =item agentnum
929
930 =item charged
931
932 List reference of charged limits (exclusive).
933
934 =item owed
935
936 List reference of charged limits (exclusive).
937
938 =item open
939
940 flag, return open invoices only
941
942 =item net
943
944 flag, return net invoices only
945
946 =item days
947
948 =item newest_percust
949
950 =back
951
952 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
953
954 =cut
955
956 sub search_sql_where {
957   my($class, $param) = @_;
958   #if ( $DEBUG ) {
959   #  warn "$me search_sql_where called with params: \n".
960   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
961   #}
962
963   my @search = ();
964
965   #agentnum
966   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
967     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
968   }
969
970 #  #refnum
971 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
972 #    push @search, "cust_main.refnum = $1";
973 #  }
974
975   #prospectnum
976   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
977     push @search, "quotation.prospectnum = $1";
978   }
979
980   #custnum
981   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
982     push @search, "cust_bill.custnum = $1";
983   }
984
985   #_date
986   if ( $param->{_date} ) {
987     my($beginning, $ending) = @{$param->{_date}};
988
989     push @search, "quotation._date >= $beginning",
990                   "quotation._date <  $ending";
991   }
992
993   #quotationnum
994   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
995     push @search, "quotation.quotationnum >= $1";
996   }
997   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
998     push @search, "quotation.quotationnum <= $1";
999   }
1000
1001 #  #charged
1002 #  if ( $param->{charged} ) {
1003 #    my @charged = ref($param->{charged})
1004 #                    ? @{ $param->{charged} }
1005 #                    : ($param->{charged});
1006 #
1007 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
1008 #                      @charged;
1009 #  }
1010
1011   my $owed_sql = FS::cust_bill->owed_sql;
1012
1013   #days
1014   push @search, "quotation._date < ". (time-86400*$param->{'days'})
1015     if $param->{'days'};
1016
1017   #agent virtualization
1018   my $curuser = $FS::CurrentUser::CurrentUser;
1019   #false laziness w/search/quotation.html
1020   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
1021                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
1022                ' )    ';
1023
1024   join(' AND ', @search );
1025
1026 }
1027
1028 =item _items_pkg
1029
1030 Return line item hashes for each package on this quotation.
1031
1032 =cut
1033
1034 sub _items_pkg {
1035   my ($self, %options) = @_;
1036   my $escape = $options{'escape_function'};
1037   my $locale = $self->cust_or_prospect->locale;
1038
1039   my $preref = $options{'preref_callback'};
1040
1041   my $section = $options{'section'};
1042   my $freq = $section->{'category'};
1043   my @pkgs = $self->quotation_pkg;
1044   my @items;
1045   die "_items_pkg called without section->{'category'}"
1046     unless defined $freq;
1047
1048   my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
1049                 # like we should have done in the first place
1050
1051   foreach my $quotation_pkg (@pkgs) {
1052     my $part_pkg = $quotation_pkg->part_pkg;
1053     my @details = $quotation_pkg->details;
1054     my $setuprecur;
1055     my $this_item = {
1056       'pkgnum'          => $quotation_pkg->quotationpkgnum,
1057       'description'     => $quotation_pkg->desc($locale),
1058       'ext_description' => \@details,
1059       'quantity'        => $quotation_pkg->quantity,
1060     };
1061     if ($freq eq '0') {
1062       # setup/one-time
1063       $setuprecur = 'setup';
1064       if ($part_pkg->freq ne '0') {
1065         # indicate that it's a setup fee on a recur package (cust_bill does 
1066         # this too)
1067         $this_item->{'description'} .= ' Setup';
1068       }
1069     } else {
1070       # recur for this frequency
1071       next if $freq ne $part_pkg->freq;
1072       $setuprecur = 'recur';
1073     }
1074
1075     $this_item->{'unit_amount'} = sprintf('%.2f', 
1076       $quotation_pkg->get('unit'.$setuprecur));
1077     $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
1078                                              * $quotation_pkg->quantity);
1079     next if $this_item->{'amount'} == 0 and !(
1080       $setuprecur eq 'setup'
1081       ? $quotation_pkg->setup_show_zero
1082       : $quotation_pkg->recur_show_zero
1083     );
1084
1085     if ( $preref ) {
1086       $this_item->{'preref_html'} = &$preref($quotation_pkg);
1087     }
1088
1089     push @items, $this_item;
1090     my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1091     if ($discount) {
1092       $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1093       push @items, $discount;
1094     }
1095
1096     # each quotation_pkg_tax has two amounts: the amount charged on the 
1097     # setup invoice, and the amount on the recurring invoice.
1098     foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1099       my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1100         'pkgnum'          => 0,
1101         'description'     => $qpt->itemdesc,
1102         'ext_description' => [],
1103         'amount'          => 0,
1104       };
1105       $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1106     }
1107   } # foreach $quotation_pkg
1108
1109   foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1110     my $this_tax = $tax_item{$taxname};
1111     $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1112     next if $this_tax->{'amount'} == 0;
1113     push @items, $this_tax;
1114   }
1115
1116   return @items;
1117 }
1118
1119 sub _items_tax {
1120   ();
1121 }
1122
1123 =back
1124
1125 =head1 BUGS
1126
1127 =head1 SEE ALSO
1128
1129 L<FS::Record>, schema.html from the base documentation.
1130
1131 =cut
1132
1133 1;
1134