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