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