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