RT#6223: Billing process - pending packages - feature request [order quotation on...
[freeside.git] / FS / FS / quotation.pm
index e24f1dd..e2f6f02 100644 (file)
@@ -7,10 +7,12 @@ use FS::CurrentUser;
 use FS::UID qw( dbh );
 use FS::Maketext qw( emt );
 use FS::Record qw( qsearch qsearchs );
 use FS::UID qw( dbh );
 use FS::Maketext qw( emt );
 use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
 use FS::cust_main;
 use FS::cust_pkg;
 use FS::prospect_main;
 use FS::quotation_pkg;
 use FS::cust_main;
 use FS::cust_pkg;
 use FS::prospect_main;
 use FS::quotation_pkg;
+use FS::quotation_pkg_tax;
 use FS::type_pkgs;
 
 =head1 NAME
 use FS::type_pkgs;
 
 =head1 NAME
@@ -63,6 +65,13 @@ disabled
 
 usernum
 
 
 usernum
 
+=item close_date
+
+projected date when the quotation will be closed
+
+=item confidence
+
+projected confidence (expressed as integer) that quotation will close
 
 =back
 
 
 =back
 
@@ -115,6 +124,8 @@ sub check {
     || $self->ut_numbern('_date')
     || $self->ut_enum('disabled', [ '', 'Y' ])
     || $self->ut_numbern('usernum')
     || $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;
 
   ;
   return $error if $error;
 
@@ -122,6 +133,9 @@ sub check {
 
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
 
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
+  return 'confidence 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;
   return 'prospectnum or custnum must be specified'
     if ! $self->prospectnum
     && ! $self->custnum;
@@ -198,10 +212,7 @@ sub email {
 
   # this is where we set the From: address
   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
 
   # this is where we set the From: address
   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
-        || ($conf->config('invoice_from_name', $self->cust_or_prospect->agentnum ) ?
-            $conf->config('invoice_from_name', $self->cust_or_prospect->agentnum ) . ' <' .
-            $conf->config('invoice_from', $self->cust_or_prospect->agentnum ) . '>' :
-            $conf->config('invoice_from', $self->cust_or_prospect->agentnum ));
+        ||  $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
   $self->SUPER::email( {
     'from' => $from,
     %$opt,
   $self->SUPER::email( {
     'from' => $from,
     %$opt,
@@ -266,34 +277,120 @@ sub cust_or_prospect_label_link {
 
 }
 
 
 }
 
-#prevent things from falsely showing up as taxes, at least until we support
-# quoting tax amounts..
 sub _items_tax {
 sub _items_tax {
-  return ();
+  ();
 }
 }
+
 sub _items_nontax {
   shift->cust_bill_pkg;
 }
 
 sub _items_total {
 sub _items_nontax {
   shift->cust_bill_pkg;
 }
 
 sub _items_total {
-  my( $self, $total_items ) = @_;
+  my $self = shift;
+  $self->quotationnum =~ /^(\d+)$/ or return ();
+
+  my @items;
+
+  # show taxes in here also; the setup/recurring breakdown is different
+  # from what Template_Mixin expects
+  my @setup_tax = qsearch({
+      select      => 'itemdesc, SUM(setup_amount) as setup_amount',
+      table       => 'quotation_pkg_tax',
+      addl_from   => ' JOIN quotation_pkg USING (quotationpkgnum) ',
+      extra_sql   => ' WHERE quotationnum = '.$1,
+      order_by    => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
+                     ' ORDER BY itemdesc',
+  });
+  # recurs need to be grouped by frequency, and to have a pkgpart
+  my @recur_tax = qsearch({
+      select      => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
+      table       => 'quotation_pkg_tax',
+      addl_from   => ' JOIN quotation_pkg USING (quotationpkgnum)'.
+                     ' JOIN part_pkg USING (pkgpart)',
+      extra_sql   => ' WHERE quotationnum = '.$1,
+      order_by    => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
+                     ' ORDER BY freq, itemdesc',
+  });
+
+  my $total_setup = $self->total_setup;
+  my $total_recur = $self->total_recur;
+  my $setup_show = $total_setup > 0 ? 1 : 0;
+  my $recur_show = $total_recur > 0 ? 1 : 0;
+  unless ($setup_show && $recur_show) {
+    foreach my $quotation_pkg ($self->quotation_pkg) {
+      $setup_show = 1 if !$setup_show and $quotation_pkg->setup_show_zero;
+      $recur_show = 1 if !$recur_show and $quotation_pkg->recur_show_zero;
+      last if $setup_show && $recur_show;
+    }
+  }
 
 
-  if ( $self->total_setup > 0 ) {
-    push @$total_items, {
-      'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
-      'total_amount' => $self->total_setup,
+  foreach my $pkg_tax (@setup_tax) {
+    if ($pkg_tax->setup_amount > 0) {
+      $total_setup += $pkg_tax->setup_amount;
+      push @items, {
+        'total_item'    => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
+        'total_amount'  => $pkg_tax->setup_amount,
+      };
+    }
+  }
+
+  if ( $setup_show ) {
+    push @items, {
+      'total_item'   => $self->mt( $recur_show ? 'Total Setup' : 'Total' ),
+      'total_amount' => sprintf('%.2f',$total_setup),
+      'break_after'  => ( scalar(@recur_tax) ? 1 : 0 )
     };
   }
 
   #could/should add up the different recurring frequencies on lines of their own
   # but this will cover the 95% cases for now
     };
   }
 
   #could/should add up the different recurring frequencies on lines of their own
   # but this will cover the 95% cases for now
-  if ( $self->total_recur > 0 ) {
-    push @$total_items, {
+  # label these with the frequency
+  foreach my $pkg_tax (@recur_tax) {
+    if ($pkg_tax->recur_amount > 0) {
+      $total_recur += $pkg_tax->recur_amount;
+      # an arbitrary part_pkg, but with the right frequency
+      # XXX localization
+      my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
+      push @items, {
+        'total_item'    => $pkg_tax->itemdesc . ' (' .  $part_pkg->freq_pretty . ')',
+        'total_amount'  => $pkg_tax->recur_amount,
+      };
+    }
+  }
+
+  if ( $recur_show ) {
+    push @items, {
       'total_item'   => $self->mt('Total Recurring'),
       'total_item'   => $self->mt('Total Recurring'),
-      'total_amount' => $self->total_recur,
+      'total_amount' => sprintf('%.2f',$total_recur),
+      'break_after'  => 1,
     };
     };
+    # show 'first payment' line (setup + recur) if there are no prorated 
+    # packages included
+    my $disable_total = 0;
+    foreach my $quotation_pkg ($self->quotation_pkg) {
+      my $part_pkg = $quotation_pkg->part_pkg;
+      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
+              )
+      ) {
+        $disable_total = 1;
+        last;
+      }
+    }
+    if (!$disable_total) {
+      push @items, {
+        'total_item'   => $self->mt('First payment'),
+        'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
+        'break_after'  => 1,
+      };
+    }
   }
 
   }
 
+  return @items;
+
 }
 
 =item enable_previous
 }
 
 =item enable_previous
@@ -302,7 +399,7 @@ sub _items_total {
 
 sub enable_previous { 0 }
 
 
 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.
 
 If this quotation already belongs to a customer, then returns that customer, as
 an FS::cust_main object.
@@ -314,10 +411,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.
 
 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;
 =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
 
   my $cust_main = $self->cust_main;
   return $cust_main if $cust_main; #already converted, don't again
@@ -334,7 +434,7 @@ sub convert_cust_main {
 
   $self->prospectnum('');
   $self->custnum( $cust_main->custnum );
 
   $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;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -346,7 +446,7 @@ sub convert_cust_main {
 
 }
 
 
 }
 
-=item order
+=item order [ HASHREF ] [ PARAMS ]
 
 This method is for use with quotations which are already associated with a customer.
 
 
 This method is for use with quotations which are already associated with a customer.
 
@@ -354,20 +454,91 @@ Orders this quotation's packages as real packages for the customer.
 
 If there is an error, returns an error message, otherwise returns false.
 
 
 If there is an error, returns an error message, otherwise returns false.
 
+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;
 =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($_) );
+    }
 
 
-  tie my %cust_pkg, 'Tie::RefHash',
-    map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
-                              quantity => $_->quantity,
-                           })
-            => [] #services
-        }
-      $self->quotation_pkg ;
+    # currently only one discount each
+    my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
+    if ( $pkg_discount ) {
+      $cust_pkg->set('discountnum', $pkg_discount->discountnum);
+    }
 
 
-  $self->cust_main->order_pkgs( \%cust_pkg );
+    $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;
+  }
+
+  ''; #no error
 
 }
 
 
 }
 
@@ -415,7 +586,7 @@ sub charge {
     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
-    $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
+    $locationnum = $_[0]->{locationnum};
   } else {
     $amount     = shift;
     $setup_cost = '';
   } else {
     $amount     = shift;
     $setup_cost = '';
@@ -536,6 +707,139 @@ sub enable {
   $self->replace();
 }
 
   $self->replace();
 }
 
+=item estimate
+
+Calculates current prices for all items on this quotation, including 
+discounts and taxes, and updates the quotation_pkg records accordingly.
+
+=cut
+
+sub estimate {
+  my $self = shift;
+  my $conf = FS::Conf->new;
+
+  my $dbh = dbh;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  # bring individual items up to date (set setup/recur and discounts)
+  my @quotation_pkg = $self->quotation_pkg;
+  foreach my $pkg (@quotation_pkg) {
+    my $error = $pkg->estimate;
+    if ($error) {
+      $dbh->rollback if $oldAutoCommit;
+      die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
+    }
+
+    # delete old tax records
+    foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
+      $error = $quotation_pkg_tax->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
+      }
+    }
+  }
+
+  # annoyingly duplicates handle_taxes--fix this in 4.x 
+  if ( $conf->exists('enable_taxproducts') ) {
+    warn "can't calculate external taxes for quotations yet\n";
+    # then we're done
+    return;
+  }
+
+  my %taxnum_exemptions; # for monthly exemptions; as yet unused
+
+  foreach my $pkg (@quotation_pkg) {
+    my $location = $pkg->cust_location;
+
+    my $part_item = $pkg->part_pkg; # we don't have fees on these yet
+    my @loc_keys = qw( district city county state country);
+    my %taxhash = map { $_ => $location->$_ } @loc_keys;
+    $taxhash{'taxclass'} = $part_item->taxclass;
+    my @taxes;
+    my %taxhash_elim = %taxhash;
+    my @elim = qw( district city county state );
+    do {
+      @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+      if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+        #then try a match without taxclass
+        my %no_taxclass = %taxhash_elim;
+        $no_taxclass{ 'taxclass' } = '';
+        @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+      }
+    
+      $taxhash_elim{ shift(@elim) } = '';
+    } while ( !scalar(@taxes) && scalar(@elim) );
+
+    foreach my $tax_def (@taxes) {
+      my $taxnum = $tax_def->taxnum;
+      $taxnum_exemptions{$taxnum} ||= [];
+
+      # XXX do some kind of equivalent to set_exemptions here
+      # but for now just declare that there are no exemptions,
+      # and then hack the taxable amounts if the package def
+      # excludes setup/recur
+      $pkg->set('cust_tax_exempt_pkg', []);
+
+      if ( $part_item->setuptax or $tax_def->setuptax ) {
+        $pkg->set('unitsetup', 0);
+      }
+      if ( $part_item->recurtax or $tax_def->recurtax ) {
+        $pkg->set('unitrecur', 0);
+      }
+
+      my %taxline;
+      foreach my $pass (qw(first recur)) {
+        if ($pass eq 'recur') {
+          $pkg->set('unitsetup', 0);
+        }
+
+        my $taxline = $tax_def->taxline(
+          [ $pkg ],
+          exemptions => $taxnum_exemptions{$taxnum}
+        );
+        if ($taxline and !ref($taxline)) {
+          $dbh->rollback if $oldAutoCommit;
+          die "error calculating '".$tax_def->taxname .
+              "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
+        }
+        $taxline{$pass} = $taxline;
+      }
+
+      my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
+          quotationpkgnum => $pkg->quotationpkgnum,
+          itemdesc        => ($tax_def->taxname || 'Tax'),
+          taxnum          => $taxnum,
+          taxtype         => ref($tax_def),
+      });
+      my $setup_amount = 0;
+      my $recur_amount = 0;
+      if ($taxline{first}) {
+        $setup_amount = $taxline{first}->setup; # "first cycle", not setup
+      }
+      if ($taxline{recur}) {
+        $recur_amount = $taxline{recur}->setup;
+        $setup_amount -= $recur_amount; # to get the actual setup amount
+      }
+      if ( $recur_amount > 0 or $setup_amount > 0 ) {
+        $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
+        $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
+
+        my $error = $quotation_pkg_tax->insert;
+        if ($error) {
+          $dbh->rollback if $oldAutoCommit;
+          die "error recording '".$tax_def->taxname .
+              "' for pkgpart '".$pkg->pkgpart."': $error\n";
+        } # if $error
+      } # else there are no non-zero taxes; continue
+    } # foreach $tax_def
+  } # foreach $pkg
+
+  $dbh->commit if $oldAutoCommit;
+  '';
+}
+
 =back
 
 =head1 CLASS METHODS
 =back
 
 =head1 CLASS METHODS
@@ -658,6 +962,21 @@ sub search_sql_where {
 
 }
 
 
 }
 
+=item _items_pkg
+
+Return line item hashes for each package on this quotation. Differs from the
+base L<FS::Template_Mixin> version in that it recalculates each quoted package
+first, and doesn't implement the "condensed" option.
+
+=cut
+
+sub _items_pkg {
+  my ($self, %options) = @_;
+  $self->estimate;
+  # run it through the Template_Mixin engine
+  return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
+}
+
 =back
 
 =head1 BUGS
 =back
 
 =head1 BUGS