[freeside-commits] branch FREESIDE_3_BRANCH updated. ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42

Mark Wells mark at 420.am
Thu Feb 12 12:17:02 PST 2015


The branch, FREESIDE_3_BRANCH has been updated
       via  ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42 (commit)
      from  9954eac1ec11d4bf1d6e7925895ce675fcdc6e22 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Feb 12 14:16:39 2015 -0600

    exempt customers from specific taxes under CCH, #18509

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 6301df2..8087304 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -885,6 +885,7 @@ sub tables_hashref {
         'taxratelocationnum',           'int',      '', '', '', '',
         'amount',                       @money_type,        '', '',
         'taxable_billpkgnum',       'int',  'NULL', '', '', '',
+        'taxclass',             'varchar',  'NULL', 10, '', '',
       ],
       'primary_key' => 'billpkgtaxratelocationnum',
       'unique' => [],
@@ -3057,6 +3058,7 @@ sub tables_hashref {
         #'custnum',      'int', '', '', '', ''
         'billpkgnum',   'int', '', '', '', '', 
         'taxnum',       'int', '', '', '', '', 
+        'taxtype',  'varchar', 'NULL', $char_d, '', '',
         'year',         'int', 'NULL', '', '', '', 
         'month',        'int', 'NULL', '', '', '', 
         'creditbillpkgnum', 'int', 'NULL', '', '', '',
@@ -3072,7 +3074,7 @@ sub tables_hashref {
       'unique' => [],
       'index'  => [ [ 'taxnum', 'year', 'month' ],
                     [ 'billpkgnum' ],
-                    [ 'taxnum' ],
+                    [ 'taxnum', 'taxtype' ],
                     [ 'creditbillpkgnum' ],
                   ],
     },
@@ -3083,6 +3085,7 @@ sub tables_hashref {
         #'custnum',      'int', '', '', '', ''
         'billpkgnum',   'int', '', '', '', '', 
         'taxnum',       'int', '', '', '', '', 
+        'taxtype',  'varchar', 'NULL', $char_d, '', '',
         'year',         'int', 'NULL', '', '', '', 
         'month',        'int', 'NULL', '', '', '', 
         'creditbillpkgnum', 'int', 'NULL', '', '', '',
@@ -3098,7 +3101,7 @@ sub tables_hashref {
       'unique' => [],
       'index'  => [ [ 'taxnum', 'year', 'month' ],
                     [ 'billpkgnum' ],
-                    [ 'taxnum' ],
+                    [ 'taxnum', 'taxtype' ],
                     [ 'creditbillpkgnum' ],
                   ],
     },
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index 4718d18..13f027b 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -203,10 +203,13 @@ sub insert {
     }
   }
 
-  my $tax_location = $self->get('cust_bill_pkg_tax_location');
-  if ( $tax_location ) {
+  foreach my $tax_link_table (qw(cust_bill_pkg_tax_location
+                                 cust_bill_pkg_tax_rate_location))
+  {
+    my $tax_location = $self->get($tax_link_table) || [];
     foreach my $link ( @$tax_location ) {
-      next if $link->billpkgtaxlocationnum; # don't try to double-insert
+      my $pkey = $link->primary_key;
+      next if $link->get($pkey); # don't try to double-insert
       # This cust_bill_pkg can be linked on either side (i.e. it can be the
       # tax or the taxed item).  If the other side is already inserted, 
       # then set billpkgnum to ours, and insert the link.  Otherwise,
@@ -222,8 +225,8 @@ sub insert {
       my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg');
       if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) {
         $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum);
-        # XXX if we ever do tax-on-tax for these, this will have to change
-        # since pkgnum will be zero
+        # XXX pkgnum is zero for tax on tax; it might be better to use
+        # the underlying package?
         $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum);
         $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum);
         $link->set('taxable_cust_bill_pkg', '');
@@ -247,18 +250,18 @@ sub insert {
   }
 
   # someday you will be as awesome as cust_bill_pkg_tax_location...
-  # but not today
-  my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
-  if ( $tax_rate_location ) {
-    foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
-      $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
-      $error = $cust_bill_pkg_tax_rate_location->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "error inserting cust_bill_pkg_tax_rate_location: $error";
-      }
-    }
-  }
+  # and today is that day
+  #my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
+  #if ( $tax_rate_location ) {
+  #  foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
+  #    $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
+  #    $error = $cust_bill_pkg_tax_rate_location->insert;
+  #    if ( $error ) {
+  #      $dbh->rollback if $oldAutoCommit;
+  #      return "error inserting cust_bill_pkg_tax_rate_location: $error";
+  #    }
+  #  }
+  #}
 
   my $fee_links = $self->get('cust_bill_pkg_fee');
   if ( $fee_links ) {
@@ -550,6 +553,138 @@ sub regularize_details {
   return;
 }
 
+=item set_exemptions TAXOBJECT, OPTIONS
+
+Sets up tax exemptions.  TAXOBJECT is the L<FS::cust_main_county> or 
+L<FS::tax_rate> record for the tax.
+
+This will deal with the following cases:
+
+=over 4
+
+=item Fully exempt customers (cust_main.tax flag) or customer classes 
+(cust_class.tax).
+
+=item Customers exempt from specific named taxes (cust_main_exemption 
+records).
+
+=item Taxes that don't apply to setup or recurring fees 
+(cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax).
+
+=item Packages that are marked as tax-exempt (part_pkg.setuptax,
+part_pkg.recurtax).
+
+=item Fees that aren't marked as taxable (part_fee.taxable).
+
+=back
+
+It does NOT deal with monthly tax exemptions, which need more context 
+than this humble little method cares to deal with.
+
+OPTIONS should include "custnum" => the customer number if this tax line
+hasn't been inserted (which it probably hasn't).
+
+Returns a list of exemption objects, which will also be attached to the 
+line item as the 'cust_tax_exempt_pkg' pseudo-field.  Inserting the line
+item will insert these records as well.
+
+=cut
+
+sub set_exemptions {
+  my $self = shift;
+  my $tax = shift;
+  my %opt = @_;
+
+  my $part_pkg  = $self->part_pkg;
+  my $part_fee  = $self->part_fee;
+
+  my $cust_main;
+  my $custnum = $opt{custnum};
+  $custnum ||= $self->cust_bill->custnum if $self->cust_bill;
+
+  $cust_main = FS::cust_main->by_key( $custnum )
+    or die "set_exemptions can't identify customer (pass custnum option)\n";
+
+  my @new_exemptions;
+  my $taxable_charged = $self->setup + $self->recur;
+  return unless $taxable_charged > 0;
+
+  ### Fully exempt customer ###
+  my $exempt_cust;
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('cust_class-tax_exempt') ) {
+    my $cust_class = $cust_main->cust_class;
+    $exempt_cust = $cust_class->tax if $cust_class;
+  } else {
+    $exempt_cust = $cust_main->tax;
+  }
+
+  ### Exemption from named tax ###
+  my $exempt_cust_taxname;
+  if ( !$exempt_cust and $tax->taxname ) {
+    $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname);
+  }
+
+  if ( $exempt_cust ) {
+
+    push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+        amount => $taxable_charged,
+        exempt_cust => 'Y',
+      });
+    $taxable_charged = 0;
+
+  } elsif ( $exempt_cust_taxname ) {
+
+    push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+        amount => $taxable_charged,
+        exempt_cust_taxname => 'Y',
+      });
+    $taxable_charged = 0;
+
+  }
+
+  my $exempt_setup = ( ($part_fee and not $part_fee->taxable)
+      or ($part_pkg and $part_pkg->setuptax)
+      or $tax->setuptax );
+
+  if ( $exempt_setup
+      and $self->setup > 0
+      and $taxable_charged > 0 ) {
+
+    push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+        amount => $self->setup,
+        exempt_setup => 'Y'
+      });
+    $taxable_charged -= $self->setup;
+
+  }
+
+  my $exempt_recur = ( ($part_fee and not $part_fee->taxable)
+      or ($part_pkg and $part_pkg->recurtax)
+      or $tax->recurtax );
+
+  if ( $exempt_recur
+      and $self->recur > 0
+      and $taxable_charged > 0 ) {
+
+    push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+        amount => $self->recur,
+        exempt_recur => 'Y'
+      });
+    $taxable_charged -= $self->recur;
+
+  }
+
+  foreach (@new_exemptions) {
+    $_->set('taxnum', $tax->taxnum);
+    $_->set('taxtype', ref($tax));
+  }
+
+  push @{ $self->cust_tax_exempt_pkg }, @new_exemptions;
+  return @new_exemptions;
+
+}
+
 =item cust_bill
 
 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
@@ -811,71 +946,47 @@ recur) of charge.
 sub disintegrate {
   my $self = shift;
   # XXX this goes away with cust_bill_pkg refactor
+  # or at least I wish it would, but it turns out to be harder than
+  # that.
 
-  my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
+  #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh?
   my %cust_bill_pkg = ();
 
-  $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
-  $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
-
-
-  #split setup and recur
-  if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
-    my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
-    $cust_bill_pkg->set('details', []);
-    $cust_bill_pkg->recur(0);
-    $cust_bill_pkg->unitrecur(0);
-    $cust_bill_pkg->type('');
-    $cust_bill_pkg_recur->setup(0);
-    $cust_bill_pkg_recur->unitsetup(0);
-    $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
-
+  my $usage_total;
+  foreach my $classnum ($self->usage_classes) {
+    my $amount = $self->usage($classnum);
+    next if $amount == 0; # though if so we shouldn't be here
+    my $usage_item = FS::cust_bill_pkg->new({
+        $self->hash,
+        'setup'     => 0,
+        'recur'     => $amount,
+        'taxclass'  => $classnum,
+        'inherit'   => $self
+    });
+    $cust_bill_pkg{$classnum} = $usage_item;
+    $usage_total += $amount;
   }
 
-  #split usage from recur
-  my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage )
-    if exists($cust_bill_pkg{recur});
-  warn "usage is $usage\n" if $DEBUG > 1;
-  if ($usage) {
-    my $cust_bill_pkg_usage =
-        new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
-    $cust_bill_pkg_usage->recur( $usage );
-    $cust_bill_pkg_usage->type( 'U' );
-    my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
-    $cust_bill_pkg{recur}->recur( $recur );
-    $cust_bill_pkg{recur}->type( '' );
-    $cust_bill_pkg{recur}->set('details', []);
-    $cust_bill_pkg{''} = $cust_bill_pkg_usage;
+  foreach (qw(setup recur)) {
+    next if ($self->get($_) == 0);
+    my $item = FS::cust_bill_pkg->new({
+        $self->hash,
+        'setup'     => 0,
+        'recur'     => 0,
+        'taxclass'  => $_,
+        'inherit'   => $self,
+    });
+    $item->set($_, $self->get($_));
+    $cust_bill_pkg{$_} = $item;
   }
 
-  #subdivide usage by usage_class
-  if (exists($cust_bill_pkg{''})) {
-    foreach my $class (grep { $_ } $self->usage_classes) {
-      my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
-      my $cust_bill_pkg_usage =
-          new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
-      $cust_bill_pkg_usage->recur( $usage );
-      $cust_bill_pkg_usage->set('details', []);
-      my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
-      $cust_bill_pkg{''}->recur( $classless );
-      $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
-    }
-    warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur
-      if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0);
-    delete $cust_bill_pkg{''}
-      unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0);
+  if ($usage_total) {
+    $cust_bill_pkg{recur}->set('recur',
+      sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total)
+    );
   }
 
-#  # sort setup,recur,'', and the rest numeric && return
-#  my @result = map { $cust_bill_pkg{$_} }
-#               sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
-#                      ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
-#                    }
-#               keys %cust_bill_pkg;
-#
-#  return (@result);
-
-   %cust_bill_pkg;
+  %cust_bill_pkg;
 }
 
 =item usage CLASSNUM
@@ -944,7 +1055,7 @@ sub usage_classes {
 sub cust_tax_exempt_pkg {
   my ( $self ) = @_;
 
-  $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
+  my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
 }
 
 =item cust_bill_pkg_fee
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index a681080..a3da331 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -8,6 +8,7 @@ use List::Util qw( min );
 use FS::UID qw( dbh );
 use FS::Record qw( qsearch qsearchs dbdef );
 use FS::Misc::DateTime qw( day_end );
+use Tie::RefHash;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
@@ -887,7 +888,9 @@ sub calculate_taxes {
   # $taxlisthash is a hashref
   # keys are identifiers, values are arrayrefs
   # each arrayref starts with a tax object (cust_main_county or tax_rate)
-  # then any cust_bill_pkg objects the tax applies to
+  # then a cust_bill_pkg object the tax applies to, then the charge class
+  # on that object (setup, recur, a usage class number, or '')
+  # For internal taxes the charge class is always undef.
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
@@ -895,88 +898,140 @@ sub calculate_taxes {
        #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n"
     if $DEBUG > 2;
 
-  my @tax_line_items = ();
-
-  # keys are tax names (as printed on invoices / itemdesc )
-  # values are arrayrefs of taxlisthash keys (internal identifiers)
+  # The main tax accumulator.  One bin for each tax name (itemdesc).
+  # For each subdivision of tax under this name, push a cust_bill_pkg item 
+  # for the calculated tax into the arrayref.
+  # keys are tax names
+  # values are arrayrefs of tax lines
   my %taxname = ();
 
   # keys are taxlisthash keys (internal identifiers)
   # values are (cumulative) amounts
   my %tax_amount = ();
 
-  # keys are taxlisthash keys (internal identifiers)
-  # values are arrayrefs of cust_bill_pkg_tax_location hashrefs
-  my %tax_location = ();
-
-  # keys are taxlisthash keys (internal identifiers)
-  # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs
-  my %tax_rate_location = ();
-
-  # keys are taxlisthash keys (internal identifiers!)
+  # keys are taxlisthash keys
   # values are arrayrefs of cust_tax_exempt_pkg objects
   my %tax_exemption;
 
-  foreach my $tax ( keys %$taxlisthash ) {
-    # $tax is a tax identifier (intersection of a tax definition record
-    # and a cust_bill_pkg record)
-    my $tax_object = shift @{ $taxlisthash->{$tax} };
+  # keys are cust_bill_pkg objects (taxable items)
+  # values are hashrefs
+  #   keys are taxlisthash keys
+  #   values are the taxlines generated for those taxes
+  tie my %item_has_tax, 'Tie::RefHash', 
+    map { $_ => {} } @$cust_bill_pkg;
+
+  foreach my $tax_id ( keys %$taxlisthash ) {
+    # $tax_id: the identifier of the tax we are calculating in this pass
+
+    my $taxables = $taxlisthash->{$tax_id};
+    my $tax_object = shift @$taxables;
     # $tax_object is a cust_main_county or tax_rate 
     # (with billpkgnum, pkgnum, locationnum set)
-    # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects
-    # (setup, recurring, usage classes)
-    warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
-    warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
+    # the rest of @{ $taxlisthash->{$tax_id} } is cust_bill_pkg objects,
+    # optionally followed by their charge classes.
+    warn "found ". $tax_object->taxname. " as $tax_id\n" if $DEBUG > 2;
+
     # taxline calculates the tax on all cust_bill_pkgs in the 
-    # first (arrayref) argument, and returns a hashref of 'name' 
-    # (the line item description) and 'amount'.
-    # It also calculates exemptions and attaches them to the cust_bill_pkgs
-    # in the argument.
-    my $taxables = $taxlisthash->{$tax};
-    my $exemptions = $tax_exemption{$tax} ||= [];
-    my $taxline = $tax_object->taxline(
-                            $taxables,
-                            'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time,
-                            'exemptions'   => $exemptions,
-                          );
-    return $taxline unless ref($taxline);
-
-    unshift @{ $taxlisthash->{$tax} }, $tax_object;
-
-    if ( $tax_object->isa('FS::cust_main_county') ) {
-      # then $taxline is a real line item
+    # first (arrayref) argument.
+    #
+    # Note that non-monthly exemptions have already been calculated and 
+    # attached to the items.  Monthly exemptions will be attached in this
+    # step.
+    my $exemptions = $tax_exemption{$tax_id} ||= [];
+    if ( $tax_object->isa('FS::tax_rate') ) { # EXTERNAL TAXES
+      # STILL have tax_rate-specific crap in here...
+      my @taxlines = $tax_object->taxline( $taxables,
+                              'custnum'      => $self->custnum,
+                              'invoice_time' => $invoice_time,
+                              'exemptions'   => $exemptions,
+                              );
+      next if !@taxlines;
+      if (!ref $taxlines[0]) {
+        # it's an error string
+        warn "error evaluating $tax_id on custnum ".$self->custnum."\n";
+        return $taxlines[0];
+      }
+      foreach my $taxline (@taxlines) {
+        push @{ $taxname{ $taxline->itemdesc } }, $taxline;
+        my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0];
+        my $taxable_item = $link->taxable_cust_bill_pkg;
+        $item_has_tax{$taxable_item}->{$tax_id} = $taxline;
+      }
+    } else { # INTERNAL TAXES
+      # we can do this in a single taxline, because it's not stupid
+
+      my $taxline =  $tax_object->taxline( $taxables,
+                        'custnum'      => $self->custnum,
+                        'invoice_time' => $invoice_time,
+                        'exemptions'   => $exemptions,
+                      );
+      next if !$taxline;
+      if (!ref $taxline) {
+        # it's an error string
+        warn "error evaluating $tax_id on custnum ".$self->custnum."\n";
+        return $taxline;
+      }
+      # if the calculated tax is zero, don't even keep it
+      next if $taxline->setup < 0.001;
       push @{ $taxname{ $taxline->itemdesc } }, $taxline;
-
-    } else {
-      # leave this as is for now
-
-      my $name   = $taxline->{'name'};
-      my $amount = $taxline->{'amount'};
-
-      #warn "adding $amount as $name\n";
-      $taxname{ $name } ||= [];
-      push @{ $taxname{ $name } }, $tax;
-
-      $tax_amount{ $tax } += $amount;
-
-      # link records between cust_main_county/tax_rate and cust_location
-      $tax_rate_location{ $tax } ||= [];
-      my $taxratelocationnum =
-        $tax_object->tax_rate_location->taxratelocationnum;
-      push @{ $tax_rate_location{ $tax }  },
-        {
-          'taxnum'             => $tax_object->taxnum, 
-          'taxtype'            => ref($tax_object),
-          'amount'             => sprintf('%.2f', $amount ),
-          'locationtaxid'      => $tax_object->location,
-          'taxratelocationnum' => $taxratelocationnum,
-        };
-    } #if ref($tax_object)...
-  } #foreach keys %$taxlisthash
+    }
+  }
+  $DB::single = 1; # XXX
+
+  # all first-tier taxes are calculated.  now for tax on tax:
+
+  foreach my $taxable_item ( @$cust_bill_pkg ) {
+    # taxes that apply to this item
+    my $this_has_tax = $item_has_tax{$taxable_item};
+
+    my $location = $taxable_item->tax_location;
+    foreach my $tax_id (keys %$this_has_tax) {
+      my ($class, $taxnum) = split(' ', $tax_id);
+      # internal taxes don't support tax_on_tax, so we don't bother with 
+      # them here.
+      next unless $class eq 'FS::tax_rate';
+
+      # for each tax item that was calculated in phase 1, get the 
+      # tax definition
+      my $tax_object = FS::tax_rate->by_key($taxnum);
+      # and find all taxes that apply to it in this location
+      my @tot = $tax_object->tax_on_tax( $location );
+      next if !@tot;
+      warn "found possible taxed taxnum $taxnum\n"
+        if $DEBUG > 2;
+      # Calculate ToT separately for each taxable item, and only if _that 
+      # item_ is already taxed under the ToT.  This is counterintuitive.
+      # See RT#5243.
+      foreach my $tot (@tot) {
+        my $tot_id = ref($tot) . ' ' . $tot->taxnum;
+        warn "checking taxnum ".$tot->taxnum.
+             " which we call ". $tot->taxname ."\n"
+          if $DEBUG > 2;
+        if ( exists $this_has_tax->{ $tot_id } ) {
+          warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
+            if $DEBUG;
+          my @taxlines = $tot->taxline(
+                            $this_has_tax->{ $tax_id }, # the first-stage tax
+                            'custnum'       => $self->custnum,
+                            'invoice_time'  => $invoice_time,
+                           );
+          next if (!@taxlines); # it didn't apply after all
+          if (!ref($taxlines[0])) {
+            warn "error evaluating $tot_id TOT on custnum ".
+              $self->custnum."\n";
+            return $taxlines[0];
+          }
+          foreach my $taxline (@taxlines) {
+            push @{ $taxname{ $taxline->itemdesc } }, $taxline;
+          }
+        } # if $has_tax
+      } # foreach my $tot (tax-on-tax rate definition)
+    } # foreach $taxnum (first-tier rate definition)
+  } # foreach $taxable_item
 
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
+  my %final_tax_items; # taxname => item
   foreach my $taxname ( keys %taxname ) {
     my @cust_bill_pkg_tax_location;
     my @cust_bill_pkg_tax_rate_location;
@@ -994,22 +1049,23 @@ sub calculate_taxes {
     my %seen = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
-      if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) {
-        # then we need to transfer the amount and the links from the
-        # line item to the new one we're creating.
-        $tax_total += $taxitem->setup;
-        foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) {
-          $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
+      next if $taxitem->get('setup') == 0;
+      # if ( ref($taxitem) eq 'FS::cust_bill_pkg' )  # always true
+      # then we need to transfer the amount and the links from the
+      # line item to the new one we're creating.
+      $tax_total += $taxitem->setup;
+      my @links = @{
+        $taxitem->get('cust_bill_pkg_tax_location') ||
+        $taxitem->get('cust_bill_pkg_tax_rate_location') ||
+        []
+      };
+      foreach my $link ( @links ) {
+        $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
+        if ($link->isa('FS::cust_bill_pkg_tax_location')) {
           push @cust_bill_pkg_tax_location, $link;
+        } elsif ($link->isa('FS::cust_bill_pkg_tax_rate_location')) {
+          push @cust_bill_pkg_tax_rate_location, $link;
         }
-      } else {
-        # the tax_rate way
-        next if $seen{$taxitem}++;
-        warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
-        $tax_total += $tax_amount{$taxitem};
-        push @cust_bill_pkg_tax_rate_location,
-          map { new FS::cust_bill_pkg_tax_rate_location $_ }
-              @{ $tax_rate_location{ $taxitem } };
       }
     }
     next unless $tax_total;
@@ -1037,10 +1093,21 @@ sub calculate_taxes {
     }
     $tax_cust_bill_pkg->set('display', \@display);
 
-    push @tax_line_items, $tax_cust_bill_pkg;
+    $final_tax_items{$taxname} = $tax_cust_bill_pkg;
+  } # foreach $taxname
+  
+  # fix ToT backlinks for taxes that have been consolidated
+  # (has to be done in a separate pass)
+  foreach my $tax_item (values %final_tax_items) {
+    foreach my $taxable_link (@{ $tax_item->cust_bill_pkg_tax_rate_location }) {
+      my $taxed_item = $taxable_link->taxable_cust_bill_pkg;
+      next if $taxed_item->pkgnum > 0; # primary taxes
+      my $taxname = $taxed_item->itemdesc;
+      $taxable_link->set('taxable_cust_bill_pkg', $final_tax_items{ $taxname });
+    }
   }
 
-  \@tax_line_items;
+  [ values %final_tax_items ]
 }
 
 sub _make_lines {
@@ -1489,6 +1556,11 @@ If not supplied, part_item will be inferred from the pkgnum or feepart of the
 cust_bill_pkg, and location from the pkgnum (or, for fees, the invnum and 
 the customer's default service location).
 
+This method will also calculate exemptions for any taxes that apply to the
+line item (using the C<set_exemptions> method of L<FS::cust_bill_pkg>) and
+attach them.  This is the only place C<set_exemptions> is called in normal
+invoice processing.
+
 =cut
 
 sub _handle_taxes {
@@ -1518,85 +1590,73 @@ sub _handle_taxes {
     my %taxes = ();
 
     my @classes;
-    push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
+    my $usage = $cust_bill_pkg->usage || 0;
+    push @classes, $cust_bill_pkg->usage_classes if $usage;
     push @classes, 'setup' if $cust_bill_pkg->setup and !$options{cancel};
-    push @classes, 'recur' if $cust_bill_pkg->recur and !$options{cancel};
-
-    my $exempt = $conf->exists('cust_class-tax_exempt')
-                   ? ( $self->cust_class ? $self->cust_class->tax : '' )
-                   : $self->tax;
+    push @classes, 'recur' if ($cust_bill_pkg->recur - $usage)
+        and !$options{cancel};
+    # that's better--probably don't even need $options{cancel} now
+    # but leave it for now, just to be safe
+    #
+    # About $options{cancel}: This protects against charging per-line or
+    # per-customer or other flat-rate surcharges on a package that's being
+    # billed on cancellation (which is an out-of-cycle bill and should only
+    # have usage charges).  See RT#29443.
+
+    # customer exemption is now handled in the 'taxline' method
+    #my $exempt = $conf->exists('cust_class-tax_exempt')
+    #               ? ( $self->cust_class ? $self->cust_class->tax : '' )
+    #               : $self->tax;
     # standardize this just to be sure
-    $exempt = ($exempt eq 'Y') ? 'Y' : '';
-  
-    if ( !$exempt ) {
+    #$exempt = ($exempt eq 'Y') ? 'Y' : '';
+    #
+    #if ( !$exempt ) {
+
+    unless (exists $taxes{''}) {
+      # unsure what purpose this serves, but last time I deleted something
+      # from here just because I didn't see the point, it actually did
+      # something important.
+      my $err_or_ref = $self->_gather_taxes($part_item, '', $location);
+      return $err_or_ref unless ref($err_or_ref);
+      $taxes{''} = $err_or_ref;
+    }
 
-      foreach my $class (@classes) {
-        my $err_or_ref = $self->_gather_taxes($part_item, $class, $location);
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{$class} = $err_or_ref;
-      }
+    # NO DISINTEGRATIONS.
+    # my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
+    #
+    # do not call taxline() with any argument except the entire set of
+    # cust_bill_pkgs on an invoice that are eligible for the tax.
 
-      unless (exists $taxes{''}) {
-        my $err_or_ref = $self->_gather_taxes($part_item, '', $location);
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{''} = $err_or_ref;
-      }
+    # only calculate exemptions once for each tax rate, even if it's used
+    # for multiple classes
+    my %tax_seen = ();
+ 
+    foreach my $class (@classes) {
+      my $err_or_ref = $self->_gather_taxes($part_item, $class, $location);
+      return $err_or_ref unless ref($err_or_ref);
+      my @taxes = @$err_or_ref;
 
-    }
+      next if !@taxes;
 
-    my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr
-    foreach my $key (keys %tax_cust_bill_pkg) {
-      # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
-      # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of 
-      # the line item.
-      # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that
-      # apply to $key-class charges.
-      my @taxes = @{ $taxes{$key} || [] };
-      my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
-
-      my %localtaxlisthash = ();
       foreach my $tax ( @taxes ) {
 
-        # this is the tax identifier, not the taxname
-        my $taxname = ref( $tax ). ' '. $tax->taxnum;
-        # $taxlisthash: keys are "setup", "recur", and usage classes.
+        my $tax_id = ref( $tax ). ' '. $tax->taxnum;
+        # $taxlisthash: keys are tax identifiers ('FS::tax_rate 123456').
         # Values are arrayrefs, first the tax object (cust_main_county
-        # or tax_rate) and then any cust_bill_pkg objects that the 
-        # tax applies to.
-        $taxlisthash->{ $taxname } ||= [ $tax ];
-        push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
-
-        $localtaxlisthash{ $taxname } ||= [ $tax ];
-        push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg;
-
-      }
+        # or tax_rate), then the cust_bill_pkg object that the 
+        # tax applies to, then the tax class (setup, recur, usage classnum).
+        $taxlisthash->{ $tax_id } ||= [ $tax ];
+        push @{ $taxlisthash->{ $tax_id  } }, $cust_bill_pkg, $class;
+
+        # determine any exemptions that apply
+        if (!$tax_seen{$tax_id}) {
+          $cust_bill_pkg->set_exemptions( $tax, custnum => $self->custnum );
+          $tax_seen{$tax_id} = 1;
+        }
 
-      warn "finding taxed taxes...\n" if $DEBUG > 2;
-      foreach my $tax ( keys %localtaxlisthash ) {
-        my $tax_object = shift @{ $localtaxlisthash{$tax} };
-        warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
-          if $DEBUG > 2;
-        next unless $tax_object->can('tax_on_tax');
-
-        foreach my $tot ( $tax_object->tax_on_tax( $location ) ) {
-          my $totname = ref( $tot ). ' '. $tot->taxnum;
-
-          warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
-            if $DEBUG > 2;
-          next unless exists( $localtaxlisthash{ $totname } ); # only increase
-                                                               # existing taxes
-          warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
-          # calculate the tax amount that the tax_on_tax will apply to
-          my $hashref_or_error = 
-            $tax_object->taxline( $localtaxlisthash{$tax} );
-          return $hashref_or_error
-            unless ref($hashref_or_error);
-          
-          # and append it to the list of taxable items
-          $taxlisthash->{ $totname } ||= [ $tot ];
-          push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
+        # tax on tax will be done later, when we actually create the tax
+        # line items
 
-        }
       }
     }
 
@@ -1636,6 +1696,7 @@ sub _handle_taxes {
     foreach (@taxes) {
       my $tax_id = 'cust_main_county '.$_->taxnum;
       $taxlisthash->{$tax_id} ||= [ $_ ];
+      $cust_bill_pkg->set_exemptions($_, custnum => $self->custnum);
       push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg;
     }
 
diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm
index e4d9c80..d9cd634 100644
--- a/FS/FS/cust_main_county.pm
+++ b/FS/FS/cust_main_county.pm
@@ -241,9 +241,6 @@ will in turn have a "taxable_cust_bill_pkg" pseudo-field linking it to one
 of the taxable items.  All of these links must be resolved as the objects
 are inserted.
 
-In addition to calculating the tax for the line items, this will calculate
-any appropriate tax exemptions and attach them to the line items.
-
 Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
 objects belong to an invoice that hasn't been inserted yet.
 
@@ -257,6 +254,10 @@ tax exemption limit if there is one.
 
 sub taxline {
   my( $self, $taxables, %opt ) = @_;
+  $taxables = [ $taxables ] unless ref($taxables) eq 'ARRAY';
+  # remove any charge class identifiers; they're not supported here
+  @$taxables = grep { ref $_ } @$taxables;
+
   return 'taxline called with no line items' unless @$taxables;
 
   local $SIG{HUP} = 'IGNORE';
@@ -283,20 +284,6 @@ sub taxline {
     die "unable to calculate taxes for an unknown customer\n";
   }
 
-  # set a flag if the customer is tax-exempt
-  my $exempt_cust;
-  my $conf = FS::Conf->new;
-  if ( $conf->exists('cust_class-tax_exempt') ) {
-    my $cust_class = $cust_main->cust_class;
-    $exempt_cust = $cust_class->tax if $cust_class;
-  } else {
-    $exempt_cust = $cust_main->tax;
-  }
-
-  # set a flag if the customer is exempt from this tax here
-  my $exempt_cust_taxname = $cust_main->tax_exemption($self->taxname)
-    if $self->taxname;
-
   # Gather any exemptions that are already attached to these cust_bill_pkgs
   # so that we can deduct them from the customer's monthly limit.
   my @existing_exemptions = @{ $opt{'exemptions'} };
@@ -314,70 +301,18 @@ sub taxline {
 
   foreach my $cust_bill_pkg (@$taxables) {
 
-    my $cust_pkg  = $cust_bill_pkg->cust_pkg;
-    my $part_pkg  = $cust_bill_pkg->part_pkg;
-    my $part_fee  = $cust_bill_pkg->part_fee;
-
-    my $locationnum = $cust_pkg
-                      ? $cust_pkg->locationnum
-                      : $cust_main->bill_locationnum;
-
-    my @new_exemptions;
-    my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
-      or next; # don't create zero-amount exemptions
-
-    # XXX the following procedure should probably be in cust_bill_pkg
-
-    if ( $exempt_cust ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $taxable_charged,
-          exempt_cust => 'Y',
-        });
-      $taxable_charged = 0;
-
-    } elsif ( $exempt_cust_taxname ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $taxable_charged,
-          exempt_cust_taxname => 'Y',
-        });
-      $taxable_charged = 0;
-
+    my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur;
+    foreach ( grep { $_->taxnum == $self->taxnum }
+              @{ $cust_bill_pkg->cust_tax_exempt_pkg }
+    ) {
+      # deal with exemptions that have been set on this line item, and 
+      # pertain to this tax def
+      $taxable_charged -= $_->amount;
     }
+ 
+    my $locationnum = $cust_bill_pkg->tax_locationnum;
 
-    my $setup_exempt = ( ($part_fee and not $part_fee->taxable)
-                      or ($part_pkg and $part_pkg->setuptax)
-                      or $self->setuptax );
-
-    if ( $setup_exempt
-        and $cust_bill_pkg->setup > 0
-        and $taxable_charged > 0 ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $cust_bill_pkg->setup,
-          exempt_setup => 'Y'
-      });
-      $taxable_charged -= $cust_bill_pkg->setup;
-
-    }
-
-    my $recur_exempt = ( ($part_fee and not $part_fee->taxable)
-                      or ($part_pkg and $part_pkg->recurtax)
-                      or $self->recurtax );
-
-    if ( $recur_exempt
-        and $cust_bill_pkg->recur > 0
-        and $taxable_charged > 0 ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $cust_bill_pkg->recur,
-          exempt_recur => 'Y'
-      });
-      $taxable_charged -= $cust_bill_pkg->recur;
-    
-    }
-  
+    ### Monthly capped exemptions ### 
     if ( $self->exempt_amount && $self->exempt_amount > 0 
       and $taxable_charged > 0 ) {
       # If the billing period extends across multiple calendar months, 
@@ -476,13 +411,21 @@ sub taxline {
             : $remaining_exemption;
           $addl = $taxable_charged if $addl > $taxable_charged;
 
-          push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+          my $new_exemption = 
+            FS::cust_tax_exempt_pkg->new({
               amount          => sprintf('%.2f', $addl),
               exempt_monthly  => 'Y',
               year            => $year,
               month           => $mon,
+              taxnum          => $self->taxnum,
+              taxtype         => ref($self)
             });
           $taxable_charged -= $addl;
+
+          # create a record of it
+          push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, $new_exemption;
+          # and allow it to be counted against the limit for other packages
+          push @existing_exemptions, $new_exemption;
         }
         # if they're using multiple months of exemption for a multi-month
         # package, then record the exemptions in separate months
@@ -495,12 +438,6 @@ sub taxline {
       }
     } # if exempt_amount
 
-    $_->taxnum($self->taxnum) foreach @new_exemptions;
-
-    # attach them to the line item
-    push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
-    push @existing_exemptions, @new_exemptions;
-
     $taxable_charged = sprintf( "%.2f", $taxable_charged);
     next if $taxable_charged == 0;
 
diff --git a/FS/FS/cust_tax_exempt_pkg.pm b/FS/FS/cust_tax_exempt_pkg.pm
index bbabb5b..29f6314 100644
--- a/FS/FS/cust_tax_exempt_pkg.pm
+++ b/FS/FS/cust_tax_exempt_pkg.pm
@@ -6,6 +6,7 @@ use FS::Record qw( qsearch qsearchs );
 use FS::cust_main_Mixin;
 use FS::cust_bill_pkg;
 use FS::cust_main_county;
+use FS::tax_rate;
 use FS::cust_credit_bill_pkg;
 use FS::UID qw(dbh);
 use FS::upgrade_journal;
@@ -50,6 +51,9 @@ currently supported:
 =item billpkgnum - invoice line item (see L<FS::cust_bill_pkg>) that 
 was exempted from tax.
 
+=item taxtype - the object class of the tax record ('FS::cust_main_county'
+or 'FS::tax_rate').
+
 =item taxnum - tax rate (see L<FS::cust_main_county>)
 
 =item year - the year in which the exemption occurred.  NULL if this 
@@ -138,7 +142,7 @@ sub check {
 
   my $error = $self->ut_numbern('exemptnum')
     || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg', 'billpkgnum')
-    || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum')
+    || $self->ut_enum('taxtype', [ 'FS::cust_main_county', 'FS::tax_rate' ])
     || $self->ut_foreign_keyn('creditbillpkgnum',
                               'cust_credit_bill_pkg',
                               'creditbillpkgnum')
@@ -152,6 +156,10 @@ sub check {
     || $self->SUPER::check
   ;
 
+  $self->get('taxtype') =~ /^FS::(\w+)$/;
+  my $rate_table = $1;
+  $error ||= $self->ut_foreign_key('taxnum', $rate_table, 'taxnum');
+
   return $error if $error;
 
   if ( $self->get('exempt_cust') ) {
@@ -178,6 +186,8 @@ sub check {
 
 =item cust_main_county
 
+=item tax_rate
+
 Returns the associated tax definition if it still exists in the database.
 Otherwise returns false.
 
@@ -185,7 +195,14 @@ Otherwise returns false.
 
 sub cust_main_county {
   my $self = shift;
-  qsearchs( 'cust_main_county', { 'taxnum', $self->taxnum } );
+  my $class = $self->taxtype;
+  $class->by_key($self->taxnum);
+}
+
+sub tax_rate {
+  my $self = shift;
+  my $class = $self->taxtype;
+  $class->by_key($self->taxnum);
 }
 
 sub _upgrade_data {
@@ -198,6 +215,19 @@ sub _upgrade_data {
     dbh->do($sql) or die dbh->errstr;
     FS::upgrade_journal->set_done($journal);
   }
+
+  $journal = 'cust_tax_exempt_pkg_taxtype';
+  if ( !FS::upgrade_journal->is_done($journal) ) {
+    my $sql = "UPDATE cust_tax_exempt_pkg ".
+              "SET taxtype = 'FS::cust_main_county' WHERE taxtype IS NULL";
+    dbh->do($sql) or die dbh->errstr;
+    $sql =    "UPDATE cust_tax_exempt_pkg_void ".
+              "SET taxtype = 'FS::cust_main_county' WHERE taxtype IS NULL";
+    dbh->do($sql) or die dbh->errstr;
+    FS::upgrade_journal->set_done($journal);
+  }
+
+
 }
 
 =back
diff --git a/FS/FS/cust_tax_exempt_pkg_void.pm b/FS/FS/cust_tax_exempt_pkg_void.pm
index bfbc8c7..ed793d5 100644
--- a/FS/FS/cust_tax_exempt_pkg_void.pm
+++ b/FS/FS/cust_tax_exempt_pkg_void.pm
@@ -110,10 +110,11 @@ and replace methods.
 sub check {
   my $self = shift;
 
-  my $error = 
+  my $error =
     $self->ut_number('exemptpkgnum')
     || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
-    || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum')
+    || $self->ut_enum('taxtype', [ 'FS::cust_main_county', 'FS::tax_rate' ])
+    || $self->ut_number('taxnum')
     || $self->ut_numbern('year')
     || $self->ut_numbern('month')
     || $self->ut_numbern('creditbillpkgnum') #no FK check, will have been del'ed
@@ -124,6 +125,11 @@ sub check {
     || $self->ut_flag('exempt_cust_taxname')
     || $self->ut_flag('exempt_monthly')
   ;
+
+  $self->get('taxtype') =~ /^FS::(\w+)$/; 
+  my $rate_table = $1;
+  $error ||= $self->ut_foreign_key('taxnum', $rate_table, 'taxnum');
+
   return $error if $error;
 
   $self->SUPER::check;
diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm
index 89cb3de..cd3ce7e 100644
--- a/FS/FS/part_pkg/voip_cdr.pm
+++ b/FS/FS/part_pkg/voip_cdr.pm
@@ -453,7 +453,7 @@ sub calc_usage {
         'disable_src'    => $self->option('disable_src'),
         'default_prefix' => $self->option('default_prefix'),
         'cdrtypenum'     => $self->option('use_cdrtypenum'),
-        'calltypenum'    => $self->option('use_calltypenum'),
+        'calltypenum'    => $self->option('use_calltypenum', 1),
         'status'         => '',
         'for_update'     => 1,
       );  # $last_bill, $$sdate )
diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm
index 9f07f36..ab1a69e 100644
--- a/FS/FS/tax_rate.pm
+++ b/FS/FS/tax_rate.pm
@@ -18,6 +18,7 @@ use MIME::Base64;
 use DBIx::DBSchema;
 use DBIx::DBSchema::Table;
 use DBIx::DBSchema::Column;
+use List::Util 'sum';
 use FS::Record qw( qsearch qsearchs dbh dbdef );
 use FS::Conf;
 use FS::tax_class;
@@ -382,57 +383,76 @@ sub passtype_name {
   $tax_passtypes{$self->passtype};
 }
 
-=item taxline TAXABLES
+#Returns a listref of a name and an amount of tax calculated for the list
+#of packages/amounts referenced by TAXABLES.  If an error occurs, a message
+#is returned as a scalar.
 
-Returns a listref of a name and an amount of tax calculated for the list
-of packages/amounts referenced by TAXABLES.  If an error occurs, a message
-is returned as a scalar.
+=item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
 
-=cut
+Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable
+line items, and returns some number of new L<FS::cust_bill_pkg> objects 
+representing the tax on them under this tax rate.  Each returned object
+will correspond to a single input line item.
 
-sub taxline {
-  my $self = shift;
-  # this used to accept a hash of options but none of them did anything
-  # so it's been removed.
+For accurate calculation of per-customer or per-location taxes, ALL items
+appearing on the invoice MUST be passed to this method together.
 
-  my $taxables;
+Optionally, any of the L<FS::cust_bill_pkg> objects may be followed in the
+array by a charge class: 'setup', 'recur', '' (for unclassified usage), or an
+integer denoting an L<FS::usage_class> number.  In this case, the tax will 
+only be charged on that portion of the line item.
 
-  if (ref($_[0]) eq 'ARRAY') {
-    $taxables = shift;
-  }else{
-    $taxables = [ @_ ];
-    #exemptions would be broken in this case
-  }
+Each returned object will have a pseudo-field,
+"cust_bill_pkg_tax_rate_location", containing a single
+L<FS::cust_bill_pkg_tax_rate_location> object.  This will in turn
+have a "taxable_cust_bill_pkg" pseudo-field linking it to one of the taxable
+items.  All of these links must be resolved as the objects are inserted.
+
+If the tax is disabled, this method will return nothing.  Be prepared for 
+that.
+
+In addition to calculating the tax for the line items, this will calculate
+tax exemptions and attach them to the line items.  I<Currently this only
+supports customer exemptions.>
+
+Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
+objects belong to an invoice that hasn't been inserted yet.
+
+The 'exemptions' option allowed in L<FS::cust_main_county::taxline> does 
+nothing here, since monthly exemptions aren't supported.
+
+=cut
+
+sub taxline {
+  my( $self, $taxables, %opt) = @_;
+  $taxables = [ $taxables ] unless ref($taxables) eq 'ARRAY';
 
   my $name = $self->taxname;
   $name = 'Other surcharges'
     if ($self->passtype == 2);
   my $amount = 0;
   
-  if ( $self->disabled ) { # we always know how to handle disabled taxes
-    return {
-      'name'   => $name,
-      'amount' => $amount,
-    };
+  return unless @$taxables; # nothing to do
+  return if $self->disabled; # tax is disabled, skip it
+  return if $self->passflag eq 'N'; # tax can't be passed to the customer
+    # but should probably still appear on the liability report--create a 
+    # cust_tax_exempt_pkg record for it?
+
+  # XXX a certain amount of false laziness with FS::cust_main_county
+  my $cust_bill = $taxables->[0]->cust_bill;
+  my $custnum = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
+  my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
+  if (!$cust_main) {
+    die "unable to calculate taxes for an unknown customer\n";
   }
 
-  my $taxable_charged = 0;
-  my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
-                      @$taxables;
+  my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum
+    or die "no tax_rate_location linked to tax_rate #".$self->taxnum."\n";
 
   warn "calculating taxes for ". $self->taxnum. " on ".
-    join (",", map { $_->pkgnum } @cust_bill_pkg)
+    join (",", map { $_->pkgnum } @$taxables)
     if $DEBUG;
 
-  if ($self->passflag eq 'N') {
-    # return "fatal: can't (yet) handle taxes not passed to the customer";
-    # until someone needs to track these in freeside
-    return {
-      'name'   => $name,
-      'amount' => 0,
-    };
-  }
-
   my $maxtype = $self->maxtype || 0;
   if ($maxtype != 0 && $maxtype != 1 
       && $maxtype != 14 && $maxtype != 15
@@ -454,54 +474,139 @@ sub taxline {
       $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
   }
 
-  unless ($self->setuptax =~ /^Y$/i) {
-    $taxable_charged += $_->setup foreach @cust_bill_pkg;
-  }
-  unless ($self->recurtax =~ /^Y$/i) {
-    $taxable_charged += $_->recur foreach @cust_bill_pkg;
-  }
+  my @tax_locations;
+  my %seen; # locationnum or pkgnum => 1
 
+  my $taxable_cents = 0;
   my $taxable_units = 0;
-  unless ($self->recurtax =~ /^Y$/i) {
+  my $tax_cents = 0;
 
-    if (( $self->unittype || 0 ) == 0) { #access line
-      my %seen = ();
-      foreach (@cust_bill_pkg) {
-        $taxable_units += $_->units
-          unless $seen{$_->pkgnum}++;
-      }
+  while (@$taxables) {
+    my $cust_bill_pkg = shift @$taxables;
+    my $class = 'all';
+    if ( defined($taxables->[0]) and !ref($taxables->[0]) ) {
+      $class = shift @$taxables;
+    }
+
+    my %usage_map = map { $_ => $cust_bill_pkg->usage($_) }
+                    $cust_bill_pkg->usage_classes;
+    my $usage_total = sum( values(%usage_map), 0 );
+
+    # determine if the item has exemptions that apply to this tax def
+    my @exemptions = grep { $_->taxnum == $self->taxnum }
+      @{ $cust_bill_pkg->cust_tax_exempt_pkg };
 
-    } elsif ($self->unittype == 1) { #minute
-      return $self->_fatal_or_null( 'fee with minute unit type' );
+    if ( $self->tax > 0 ) {
 
-    } elsif ($self->unittype == 2) { #account
+      my $taxable_charged = 0;
+      if ($class eq 'all') {
+        $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur;
+      } elsif ($class eq 'setup') {
+        $taxable_charged = $cust_bill_pkg->setup;
+      } elsif ($class eq 'recur') {
+        $taxable_charged = $cust_bill_pkg->recur - $usage_total;
+      } else {
+        $taxable_charged = $usage_map{$class} || 0;
+      }
 
-      my $conf = new FS::Conf;
-      if ( $conf->exists('tax-pkg_address') ) {
-        #number of distinct locations
-        my %seen = ();
-        foreach (@cust_bill_pkg) {
-          $taxable_units++
-            unless $seen{$_->cust_pkg->locationnum}++;
+      foreach my $ex (@exemptions) {
+        # the only cases where the exemption doesn't apply:
+        # if it's a setup exemption and $class is not 'setup' or 'all'
+        # if it's a recur exemption and $class is 'setup'
+        if (   ( $ex->exempt_recur and $class eq 'setup' ) 
+            or ( $ex->exempt_setup and $class ne 'setup' and $class ne 'all' )
+        ) {
+          next;
         }
+
+        $taxable_charged -= $ex->amount;
+      }
+      # cust_main_county handles monthly capped exemptions; this doesn't.
+      #
+      # $taxable_charged can also be less than zero at this point 
+      # (recur exemption + usage class breakdown); treat that as zero.
+      next if $taxable_charged <= 0;
+
+      # yeah, some false laziness with cust_main_county
+      my $this_tax_cents = int(100 * $taxable_charged * $self->tax);
+      my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({
+          'taxnum'                => $self->taxnum,
+          'taxtype'               => ref($self),
+          'cents'                 => $this_tax_cents, # not a real field
+          'locationtaxid'         => $self->location, # fundamentally silly
+          'taxable_cust_bill_pkg' => $cust_bill_pkg,
+          'taxratelocationnum'    => $taxratelocationnum,
+          'taxclass'              => $class,
+      });
+      push @tax_locations, $tax_location;
+
+      $taxable_cents += 100 * $taxable_charged;
+      $tax_cents += $this_tax_cents;
+
+    } elsif ( $self->fee > 0 ) {
+      # most CCH taxes are this type, because nearly every county has a 911
+      # fee
+      my $units = 0;
+
+      # since we don't support partial exemptions (except setup/recur), 
+      # if there's an exemption that applies to this package and taxrate, 
+      # don't charge ANY per-unit fees
+      next if @exemptions;
+
+      # don't apply fees to usage classes (maybe if we ever get per-minute
+      # fees?)
+      next unless $class eq 'setup'
+              or  $class eq 'recur'
+              or  $class eq 'all';
+      
+      if ( $self->unittype == 0 ) {
+        if ( !$seen{$cust_bill_pkg->pkgnum} ) {
+          # per access line
+          $units = $cust_bill_pkg->units;
+          $seen{$cust_bill_pkg->pkgnum} = 1;
+        } # else it's been seen, leave it at zero units
+
+      } elsif ($self->unittype == 1) { # per minute
+        # STILL not supported...fortunately these only exist if you happen
+        # to be in Idaho or Little Rock, Arkansas
+        #
+        # though a voip_cdr package could easily report minutes of usage...
+        return $self->_fatal_or_null( 'fee with minute unit type' );
+
+      } elsif ( $self->unittype == 2 ) {
+
+        # per account
+        $units = 1 unless $seen{$cust_bill_pkg->tax_locationnum};
+        $seen{$cust_bill_pkg->tax_locationnum} = 1;
+
       } else {
-        $taxable_units = 1;
+        # Unittype 19 is used for prepaid wireless E911 charges in many states.
+        # Apparently "per retail purchase", which for us would mean per invoice.
+        # Unittype 20 is used for some 911 surcharges and I have no idea what 
+        # it means.
+        return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
       }
+      my $this_tax_cents = int($units * $self->fee * 100);
+      my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({
+          'taxnum'                => $self->taxnum,
+          'taxtype'               => ref($self),
+          'cents'                 => $this_tax_cents,
+          'locationtaxid'         => $self->location,
+          'taxable_cust_bill_pkg' => $cust_bill_pkg,
+          'taxratelocationnum'    => $taxratelocationnum,
+      });
+      push @tax_locations, $tax_location;
+
+      $taxable_units += $units;
+      $tax_cents += $this_tax_cents;
 
-    } else {
-      return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
     }
+  } # foreach $cust_bill_pkg
 
-  }
-
-  # XXX handle excessrate (use_excessrate) / excessfee /
-  #            taxbase/feebase / taxmax/feemax
-  #            and eventually exemptions
-  #
-  # the tax or fee is applied to taxbase or feebase and then
-  # the excessrate or excess fee is applied to taxmax or feemax
+  # check bracket maxima; throw an error if we've gone over, because
+  # we don't really implement them
 
-  if ( ($self->taxmax > 0 and $taxable_charged > $self->taxmax) or
+  if ( ($self->taxmax > 0 and $taxable_cents > $self->taxmax*100 ) or
        ($self->feemax > 0 and $taxable_units > $self->feemax) ) {
     # throw an error
     # (why not just cap taxable_charged/units at the taxmax/feemax? because
@@ -510,17 +615,42 @@ sub taxline {
     return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum );
   }
 
-  $amount += $taxable_charged * $self->tax;
-  $amount += $taxable_units * $self->fee;
-  
-  warn "calculated taxes as [ $name, $amount ]\n"
-    if $DEBUG;
+  # round and distribute
+  my $total_tax_cents = sprintf('%.0f',
+    ($taxable_cents * $self->tax) + ($taxable_units * $self->fee * 100)
+  );
+  my $extra_cents = sprintf('%.0f', $total_tax_cents - $tax_cents);
+  $tax_cents += $extra_cents;
+  my $i = 0;
+  foreach (@tax_locations) { # can never require more than a single pass, yes?
+    my $cents = $_->get('cents');
+    if ( $extra_cents > 0 ) {
+      $cents++;
+      $extra_cents--;
+    }
+    $_->set('amount', sprintf('%.2f', $cents/100));
+  }
 
-  return {
-    'name'   => $name,
-    'amount' => $amount,
-  };
+  # just transform each CBPTRL record into a tax line item.
+  # calculate_taxes will consolidate them, but before that happens we have
+  # to do tax on tax calculation.
+  my @tax_items;
+  foreach (@tax_locations) {
+    next if $_->amount == 0;
+    my $tax_item = FS::cust_bill_pkg->new({
+        'pkgnum'        => 0,
+        'recur'         => 0,
+        'setup'         => $_->amount,
+        'sdate'         => '', # $_->sdate?
+        'edate'         => '',
+        'itemdesc'      => $name,
+        'cust_bill_pkg_tax_rate_location' => [ $_ ],
+    });
+    $_->set('tax_cust_bill_pkg' => $tax_item);
+    push @tax_items, $tax_item;
+  }
 
+  return @tax_items;
 }
 
 sub _fatal_or_null {

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Schema.pm                   |    7 +-
 FS/FS/cust_bill_pkg.pm            |  259 ++++++++++++++++++--------
 FS/FS/cust_main/Billing.pm        |  363 ++++++++++++++++++++++---------------
 FS/FS/cust_main_county.pm         |  109 +++--------
 FS/FS/cust_tax_exempt_pkg.pm      |   34 +++-
 FS/FS/cust_tax_exempt_pkg_void.pm |   10 +-
 FS/FS/part_pkg/voip_cdr.pm        |    2 +-
 FS/FS/tax_rate.pm                 |  286 +++++++++++++++++++++--------
 8 files changed, 674 insertions(+), 396 deletions(-)




More information about the freeside-commits mailing list