show a total range for prorate quotations
[freeside.git] / FS / FS / quotation.pm
index 7f53e31..1a6641f 100644 (file)
@@ -3,13 +3,17 @@ use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
 
 use strict;
 use Tie::RefHash;
 
 use strict;
 use Tie::RefHash;
+use FS::CurrentUser;
 use FS::UID qw( dbh );
 use FS::UID qw( dbh );
+use FS::Maketext qw( emt );
 use FS::Record qw( qsearch qsearchs );
 use FS::Record qw( qsearch qsearchs );
-use FS::CurrentUser;
+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
 
 
 =head1 NAME
 
@@ -61,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
 
@@ -113,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;
 
@@ -120,6 +133,14 @@ sub check {
 
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
 
   $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;
+
   $self->SUPER::check;
 }
 
   $self->SUPER::check;
 }
 
@@ -179,34 +200,207 @@ sub _total {
 
 }
 
 
 }
 
-#prevent things from falsely showing up as taxes, at least until we support
-# quoting tax amounts..
+sub email {
+  my $self = shift;
+  my $opt = shift || {};
+  if ($opt and !ref($opt)) {
+    die ref($self). '->email called with positional parameters';
+  }
+
+  my $conf = $self->conf;
+
+  my $from = delete $opt->{from};
+
+  # this is where we set the From: address
+  $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
+        ||  $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
+  $self->SUPER::email( {
+    'from' => $from,
+    %$opt,
+  });
+
+}
+
+sub email_subject {
+  my $self = shift;
+
+  my $subject =
+    $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
+      || 'Quotation';
+
+  #my $cust_main = $self->cust_main;
+  #my $name = $cust_main->name;
+  #my $name_short = $cust_main->name_short;
+  #my $invoice_number = $self->invnum;
+  #my $invoice_date = $self->_date_pretty;
+
+  eval qq("$subject");
+}
+
+=item cust_or_prosect
+
+=cut
+
+sub cust_or_prospect {
+  my $self = shift;
+  $self->custnum ? $self->cust_main : $self->prospect_main;
+}
+
+=item cust_or_prospect_label_link P
+
+HTML links to either the customer or prospect.
+
+Returns a list consisting of two elements.  The first is a text label for the
+link, and the second is the URL.
+
+=cut
+
+sub cust_or_prospect_label_link {
+  my( $self, $p ) = @_;
+
+  if ( my $custnum = $self->custnum ) {
+    my $display_custnum = $self->cust_main->display_custnum;
+    my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
+                   ? '#quotations'
+                   : ';show=quotations';
+    (
+      emt("View this customer (#[_1])",$display_custnum) =>
+        "${p}view/cust_main.cgi?custnum=$custnum$target"
+    );
+  } elsif ( my $prospectnum = $self->prospectnum ) {
+    (
+      emt("View this prospect (#[_1])",$prospectnum) =>
+        "${p}view/prospect_main.html?$prospectnum"
+    );
+  } else { #die?
+    ( '', '' );
+  }
+
+}
+
 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;
+    }
+  }
+
+  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 ( $self->total_setup > 0 ) {
-    push @$total_items, {
-      'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
-      'total_amount' => $self->total_setup,
+  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,
     };
     };
+
+    my $prorate_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
+              )
+      ) {
+        $prorate_total = 1;
+        last;
+      }
+    }
+
+    if ( $prorate_total ) {
+      push @items, {
+        'total_item'   => $self->mt('First payment (depending on day of month)'),
+        'total_amount' => [ sprintf('%.2f', $total_setup),
+                            sprintf('%.2f', $total_setup + $total_recur)
+                          ],
+        'break_after'  => 1,
+      };
+    } else {
+      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
@@ -215,7 +409,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.
@@ -227,10 +421,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
@@ -247,7 +444,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;
@@ -259,7 +456,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.
 
@@ -267,23 +464,392 @@ 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($_) );
+    }
+
+    # currently only one discount each
+    my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
+    if ( $pkg_discount ) {
+      $cust_pkg->set('discountnum', $pkg_discount->discountnum);
+    }
+
+    $all_cust_pkg{$cust_pkg} = []; # no services
+  }
 
 
-  tie my %cust_pkg, 'Tie::RefHash',
-    map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
-                              quantity => $_->quantity,
-                           })
-            => [] #services
-        }
-      $self->quotation_pkg ;
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
 
 
-  $self->cust_main->order_pkgs( \%cust_pkg );
+  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
 
 }
 
 
 }
 
+=item quotation_pkg
+
+=cut
+
+sub quotation_pkg {
+  my $self = shift;
+  qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
+}
+
+=item charge
+
+One-time charges, like FS::cust_main::charge()
+
+=cut
+
+#super false laziness w/cust_main::charge
+sub charge {
+  my $self = shift;
+  my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
+  my ( $pkg, $comment, $additional );
+  my ( $setuptax, $taxclass );   #internal taxes
+  my ( $taxproduct, $override ); #vendor (CCH) taxes
+  my $no_auto = '';
+  my $cust_pkg_ref = '';
+  my ( $bill_now, $invoice_terms ) = ( 0, '' );
+  my $locationnum;
+  if ( ref( $_[0] ) ) {
+    $amount     = $_[0]->{amount};
+    $setup_cost = $_[0]->{setup_cost};
+    $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
+    $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
+    $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
+    $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
+    $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
+                                           : '$'. sprintf("%.2f",$amount);
+    $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
+    $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
+    $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
+    $additional = $_[0]->{additional} || [];
+    $taxproduct = $_[0]->{taxproductnum};
+    $override   = { '' => $_[0]->{tax_override} };
+    $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};
+  } else {
+    $amount     = shift;
+    $setup_cost = '';
+    $quantity   = 1;
+    $start_date = '';
+    $pkg        = @_ ? shift : 'One-time charge';
+    $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
+    $setuptax   = '';
+    $taxclass   = @_ ? shift : '';
+    $additional = [];
+  }
+
+  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 $part_pkg = new FS::part_pkg ( {
+    'pkg'           => $pkg,
+    'comment'       => $comment,
+    'plan'          => 'flat',
+    'freq'          => 0,
+    'disabled'      => 'Y',
+    'classnum'      => ( $classnum ? $classnum : '' ),
+    'setuptax'      => $setuptax,
+    'taxclass'      => $taxclass,
+    'taxproductnum' => $taxproduct,
+    'setup_cost'    => $setup_cost,
+  } );
+
+  my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
+                        ( 0 .. @$additional - 1 )
+                  ),
+                  'additional_count' => scalar(@$additional),
+                  'setup_fee' => $amount,
+                );
+
+  my $error = $part_pkg->insert( options       => \%options,
+                                 tax_overrides => $override,
+                               );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  my $pkgpart = $part_pkg->pkgpart;
+
+  #DIFF
+  my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
+
+  unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
+    my $type_pkgs = new FS::type_pkgs \%type_pkgs;
+    $error = $type_pkgs->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  #except for DIFF, eveything above is idential to cust_main version
+  #but below is our own thing pretty much (adding a quotation package instead
+  # 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,
+  } );
+
+  $error = $quotation_pkg->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  #} elsif ( $cust_pkg_ref ) {
+  #  ${$cust_pkg_ref} = $cust_pkg;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  return '';
+
+}
+
+=item disable
+
+Disables this quotation (sets disabled to Y, which hides the quotation on
+prospects and customers).
+
+If there is an error, returns an error message, otherwise returns false.
+
+=cut
+
+sub disable {
+  my $self = shift;
+  $self->disabled('Y');
+  $self->replace();
+}
+
+=item enable
+
+Enables this quotation.
+
+If there is an error, returns an error message, otherwise returns false.
+
+=cut
+
+sub enable {
+  my $self = shift;
+  $self->disabled('');
+  $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
@@ -406,6 +972,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