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