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