don't leave quotation side effects around, eek, RT#79310, RT#32489
[freeside.git] / FS / FS / quotation.pm
index 45f3522..a3f0612 100644 (file)
@@ -69,6 +69,13 @@ disabled
 
 usernum
 
+=item close_date
+
+projected date when the quotation will be closed
+
+=item confidence
+
+projected confidence (expressed as integer) that quotation will close
 
 =back
 
@@ -122,6 +129,8 @@ sub check {
     || $self->ut_numbern('_date')
     || $self->ut_enum('disabled', [ '', 'Y' ])
     || $self->ut_numbern('usernum')
+    || $self->ut_numbern('close_date')
+    || $self->ut_numbern('confidence')
   ;
   return $error if $error;
 
@@ -129,6 +138,10 @@ sub check {
 
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
+  return 'confidence percentage must be an integer between 1 and 100'
+    if length($self->confidence)
+    && ( ($self->confidence < 1) || ($self->confidence > 100) );
+
   return 'prospectnum or custnum must be specified'
     if ! $self->prospectnum
     && ! $self->custnum;
@@ -214,6 +227,11 @@ sub email_subject {
   eval qq("$subject");
 }
 
+sub pdf_filename {
+  my $self = shift;
+  'Quotation-'. $self->quotationnum. '.pdf';
+}
+
 =item cust_or_prosect
 
 =cut
@@ -260,18 +278,43 @@ sub _items_sections {
   my %opt = @_;
   my $escape = $opt{escape}; # the only one we care about
 
-  my %subtotals; # package frequency => subtotal
+
+  my %show; # package frequency => 1 if there's anything to display
+  my %subtotals = (); # package frequency => subtotal
+  my $prorate_total = 0;
   foreach my $pkg ($self->quotation_pkg) {
-    my $recur_freq = $pkg->part_pkg->freq;
+
+    my $part_pkg = $pkg->part_pkg;
+
+    my $recur_freq = $part_pkg->freq;
+    $show{$recur_freq} = 1 if $pkg->unitrecur > 0 or $pkg->recur_show_zero;
+    $show{0} = 1 if $pkg->unitsetup > 0 or $pkg->setup_show_zero;
     ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
     ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
+
+    #this is a shitty hack based on what's in part_pkg/ at the moment
+    # but its good enough for the 99% common case of preventing totals from
+    # displaying for prorate packages
+    $prorate_total = 1
+      if $part_pkg->plan =~ /^(prorate|torrus|agent$)/
+      || $part_pkg->option('recur_method') eq 'prorate'
+      || ( $part_pkg->option('sync_bill_date')
+             && $self->custnum
+             && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
+         );
+
+    #possible improvement: keep track of flat vs. prorate totals to make the
+    # bottom range more accurate when mixing flat and prorate packages
+
   }
   my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
 
   my @sections;
+  my $no_recurring = 0;
   foreach my $freq (keys %subtotals) {
 
-    next if $subtotals{$freq} == 0;
+    #next if $subtotals{$freq} == 0;
+    next if !$show{$freq};
 
     my $weight = 
       List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
@@ -279,6 +322,7 @@ sub _items_sections {
     if ( $freq eq '0' ) {
       if ( scalar(keys(%subtotals)) == 1 ) {
         # there are no recurring packages
+        $no_recurring = 1;
         $desc = $self->mt('Charges');
       } else {
         $desc = $self->mt('Setup Charges');
@@ -295,6 +339,34 @@ sub _items_sections {
       'subtotal'    => sprintf('%.2f',$subtotals{$freq}),
     };
   }
+
+  unless ( $no_recurring ) {
+    my $total = 0;
+    $total += $_ for values %subtotals;
+    my %total = (
+      'sort_weight' => 0,
+      'category'    => 'Total category', #required but what's it used for?
+    );
+
+    if ( $prorate_total ) {
+
+      push @sections, {
+        %total,
+        'description' => 'First payment (depending on day of month)',
+        'subtotal'    => [ $subtotals{0}, $total ],
+      };
+
+    } else {
+
+      push @sections, {
+        %total,
+        'description' => 'First payment',
+        'subtotal'    => $total,
+      };
+    }
+
+  }
+
   return \@sections, [];
 }
 
@@ -304,7 +376,7 @@ sub _items_sections {
 
 sub enable_previous { 0 }
 
-=item convert_cust_main
+=item convert_cust_main [ PARAMS ]
 
 If this quotation already belongs to a customer, then returns that customer, as
 an FS::cust_main object.
@@ -316,10 +388,13 @@ packages as real packages for the customer.
 If there is an error, returns an error message, otherwise, returns the
 newly-created FS::cust_main object.
 
+Accepts the same params as L</order>.
+
 =cut
 
 sub convert_cust_main {
   my $self = shift;
+  my $params = shift || {};
 
   my $cust_main = $self->cust_main;
   return $cust_main if $cust_main; #already converted, don't again
@@ -336,7 +411,7 @@ sub convert_cust_main {
 
   $self->prospectnum('');
   $self->custnum( $cust_main->custnum );
-  my $error = $self->replace || $self->order;
+  my $error = $self->replace || $self->order(undef,$params);
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -348,7 +423,7 @@ sub convert_cust_main {
 
 }
 
-=item order [ HASHREF ]
+=item order [ HASHREF ] [ PARAMS ]
 
 This method is for use with quotations which are already associated with a customer.
 
@@ -360,39 +435,88 @@ If HASHREF is passed, it will be filled with a hash mapping the
 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
 as ordered.
 
+If PARAMS hashref is passed, the following params are accepted:
+
+onhold - if true, suspends newly ordered packages
+
 =cut
 
 sub order {
   my $self = shift;
   my $pkgnum_map = shift || {};
+  my $params = shift || {};
+  my $details_map = {};
 
   tie my %all_cust_pkg, 'Tie::RefHash';
   foreach my $quotation_pkg ($self->quotation_pkg) {
     my $cust_pkg = FS::cust_pkg->new;
     $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
 
+    # details will be copied below, after package is ordered
+    $details_map->{ $quotation_pkg->quotationpkgnum } = [ 
+      map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
+    ];
+
     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
       $cust_pkg->set( $_, $quotation_pkg->get($_) );
     }
 
-    # currently only one discount each
-    my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
-    if ( $pkg_discount ) {
-      $cust_pkg->set('discountnum', $pkg_discount->discountnum);
+    # can now have two discounts each (setup and recur)
+    foreach my $pkg_discount ($quotation_pkg->quotation_pkg_discount) {
+      my $field = $pkg_discount->setuprecur . '_discountnum';
+      $cust_pkg->set($field, $pkg_discount->discountnum);
     }
 
     $all_cust_pkg{$cust_pkg} = []; # no services
   }
 
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
   my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
   
+  unless ($error) {
+    # copy details (copy_on_order filtering handled above)
+    foreach my $quotationpkgnum (keys %$details_map) {
+      next unless @{$details_map->{$quotationpkgnum}};
+      $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
+        'I',
+        @{$details_map->{$quotationpkgnum}}
+      );
+      last if $error;
+    }
+  }
+
+  if ($$params{'onhold'}) {
+    foreach my $quotationpkgnum (keys %$pkgnum_map) {
+      last if $error;
+      $error = $pkgnum_map->{$quotationpkgnum}->suspend();
+    }
+  }
+
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
   foreach my $quotationpkgnum (keys %$pkgnum_map) {
     # convert the objects to just pkgnums
     my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
     $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
   }
 
-  $error;
+  ''; #no error
+
 }
 
 =item charge
@@ -412,6 +536,7 @@ sub charge {
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
   my $locationnum;
+  my ( $discountnum, $discountnum_amount, $discountnum_percent ) = ( '','','' );
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $setup_cost = $_[0]->{setup_cost};
@@ -431,6 +556,9 @@ sub charge {
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
     $locationnum = $_[0]->{locationnum};
+    $discountnum = $_->{setup_discountnum};
+    $discountnum_amount = $_->{setup_discountnum_amount};
+    $discountnum_percent = $_->{setup_discountnum_percent};
   } else {
     $amount     = shift;
     $setup_cost = '';
@@ -501,12 +629,15 @@ sub charge {
   # of ordering a customer package, no "bill now")
 
   my $quotation_pkg = new FS::quotation_pkg ( {
-    'quotationnum'  => $self->quotationnum,
-    'pkgpart'       => $pkgpart,
-    'quantity'      => $quantity,
-    #'start_date' => $start_date,
-    #'no_auto'    => $no_auto,
-    'locationnum'=> $locationnum,
+    'quotationnum'              => $self->quotationnum,
+    'pkgpart'                   => $pkgpart,
+    'quantity'                  => $quantity,
+    #'start_date'                => $start_date,
+    #'no_auto'                   => $no_auto,
+    'locationnum'               => $locationnum,
+    'setup_discountnum'         => $discountnum,
+    'setup_discountnum_amount'  => $discountnum_amount,
+    'setup_discountnum_percent' => $discountnum_percent,
   } );
 
   $error = $quotation_pkg->insert;
@@ -571,6 +702,7 @@ sub estimate {
 
   ###### BEGIN TRANSACTION ######
   local $@;
+  local $SIG{__DIE__};
   eval {
     my $temp_dbh = myconnect();
     local $FS::UID::dbh = $temp_dbh;
@@ -584,7 +716,10 @@ sub estimate {
     my $cust_main;
     if ( $cust_or_prospect->isa('FS::prospect_main') ) {
       $cust_main = $cust_or_prospect->convert_cust_main;
-      die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
+      unless ( ref($cust_main) ) {
+        $temp_dbh->rollback;
+        die "$cust_main (simulating customer signup)\n";
+      }
       $fake_self->set('prospectnum', '');
       $fake_self->set('custnum', $cust_main->custnum);
     } else {
@@ -592,8 +727,12 @@ sub estimate {
     }
 
     # order packages
+    local($FS::cust_pkg::disable_start_on_hold) = 1;
     $error = $fake_self->order(\%pkgnum_of);
-    die "$error (simulating package order)\n" if $error;
+    if ( $error ) {
+      $temp_dbh->rollback;
+      die "$error (simulating package order)\n";
+    }
 
     my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
 
@@ -606,7 +745,10 @@ sub estimate {
       'no_usage_reset'  => 1,
     );
     $error = $cust_main->bill(%bill_opt);
-    die "$error (simulating initial billing)\n" if $error;
+    if ( $error ) {
+      $temp_dbh->rollback;
+      die "$error (simulating initial billing)\n" if $error;
+    }
 
     # pick dates for future bills
     my %next_bill_pkgs;
@@ -622,7 +764,10 @@ sub estimate {
       $bill_opt{'return_bill'} = $return_bill[$i] = [];
       $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
       $error = $cust_main->bill(%bill_opt);
-      die "$error (simulating recurring billing cycle $i)\n" if $error;
+      if ( $error ) {
+        $temp_dbh->rollback;
+        die "$error (simulating recurring billing cycle $i)\n";
+      }
       $i++;
     }
 
@@ -654,7 +799,7 @@ sub estimate {
   }
 
   my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
-  my %quotation_pkg_discount; # quotationpkgnum => quotation_pkg_discount obj
+  my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
 
   for (my $i = 0; $i < scalar(@return_bill); $i++) {
     my $this_bill = $return_bill[$i]->[0];
@@ -694,25 +839,25 @@ sub estimate {
 
       # discounts
       if ( $cust_bill_pkg->get('discounts') ) {
+        # discount records are generated as (setup, recur).
+        # well, not always, sometimes it's just (recur), but fixing this
+        # is horribly invasive.
         my $discount = $cust_bill_pkg->get('discounts')->[0];
+
         if ( $discount ) {
-          # discount records are generated as (setup, recur).
-          # well, not always, sometimes it's just (recur), but fixing this
-          # is horribly invasive.
-          my $qpd = $quotation_pkg_discount{$quotationpkgnum}
+          # find the quotation_pkg_discount record for this billing pass...
+          my $setuprecur = $i ? 'recur' : 'setup';
+          my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
                 ||= qsearchs('quotation_pkg_discount', {
-                    'quotationpkgnum' => $quotationpkgnum
+                    'quotationpkgnum' => $quotationpkgnum,
+                    'setuprecur'      => $setuprecur,
                     });
 
           if (!$qpd) { #can't happen
-            warn "$me simulated bill returned a discount but no discount is in effect.\n";
+            warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
           }
-          if ($discount and $qpd) {
-            if ( $i == 0 ) {
-              $qpd->set('setup_amount', $discount->amount);
-            } else {
-              $qpd->set('recur_amount', $discount->amount);
-            }
+          if ($qpd) {
+            $qpd->set('amount', $discount->amount);
           }
         }
       } # end of discount stuff
@@ -781,10 +926,13 @@ sub estimate {
     return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
       if $error;
   }
-  foreach my $quotation_pkg_discount (values %quotation_pkg_discount) {
-    $error = $quotation_pkg_discount->replace;
-    return "$error (recording estimated discount)"
-      if $error;
+  foreach (values %quotation_pkg_discount) {
+    # { setup => one, recur => another }
+    foreach my $quotation_pkg_discount (values %$_) {
+      $error = $quotation_pkg_discount->replace;
+      return "$error (recording estimated discount)"
+        if $error;
+    }
   }
   foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
     $error = $quotation_pkg_tax->insert;
@@ -941,11 +1089,12 @@ sub _items_pkg {
 
   foreach my $quotation_pkg (@pkgs) {
     my $part_pkg = $quotation_pkg->part_pkg;
+    my @details = $quotation_pkg->details;
     my $setuprecur;
     my $this_item = {
       'pkgnum'          => $quotation_pkg->quotationpkgnum,
       'description'     => $quotation_pkg->desc($locale),
-      'ext_description' => [],
+      'ext_description' => \@details,
       'quantity'        => $quotation_pkg->quantity,
     };
     if ($freq eq '0') {
@@ -966,7 +1115,11 @@ sub _items_pkg {
       $quotation_pkg->get('unit'.$setuprecur));
     $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
                                              * $quotation_pkg->quantity);
-    next if $this_item->{'amount'} == 0;
+    next if $this_item->{'amount'} == 0 and !(
+      $setuprecur eq 'setup'
+      ? $quotation_pkg->setup_show_zero
+      : $quotation_pkg->recur_show_zero
+    );
 
     if ( $preref ) {
       $this_item->{'preref_html'} = &$preref($quotation_pkg);