[freeside-commits] branch master updated. 76d6fe17d02b77301619065ad43d7300432e977c

Mark Wells mark at 420.am
Tue Mar 31 23:54:34 PDT 2015


The branch, master has been updated
       via  76d6fe17d02b77301619065ad43d7300432e977c (commit)
      from  8c720e8a4aae1937cf837009c864aebc64faa5b4 (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 76d6fe17d02b77301619065ad43d7300432e977c
Author: Mark Wells <mark at freeside.biz>
Date:   Wed Apr 1 01:54:21 2015 -0500

    CCH tax exemptions + 4.x tax system, #34223

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index c0dd2b4..4bc3598 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1151,6 +1151,7 @@ sub tables_hashref {
         'amount',                 @money_type,                   '', '',
         'currency',                    'char', 'NULL',        3, '', '',
         'taxable_billpkgnum',           'int', 'NULL',       '', '', '',
+        'taxclass',                 'varchar', 'NULL',       10, '', '',
       ],
       'primary_key'  => 'billpkgtaxratelocationnum',
       'unique'       => [],
@@ -4534,6 +4535,7 @@ sub tables_hashref {
         #'custnum',      'int', '', '', '', ''
         'billpkgnum',   'int', '', '', '', '', 
         'taxnum',       'int', '', '', '', '', 
+        'taxtype',  'varchar', 'NULL', $char_d, '', '',
         'year',         'int', 'NULL', '', '', '', 
         'month',        'int', 'NULL', '', '', '', 
         'creditbillpkgnum', 'int', 'NULL', '', '', '',
@@ -4549,16 +4551,13 @@ sub tables_hashref {
       'unique'       => [],
       'index'        => [ [ 'taxnum', 'year', 'month' ],
                           [ 'billpkgnum' ],
-                          [ 'taxnum' ],
+                          [ 'taxnum', 'taxtype' ],
                           [ 'creditbillpkgnum' ],
                         ],
       'foreign_keys' => [
                           { columns    => [ 'billpkgnum' ],
                             table      => 'cust_bill_pkg',
                           },
-                          { columns    => [ 'taxnum' ],
-                            table      => 'cust_main_county',
-                          },
                           { columns    => [ 'creditbillpkgnum' ],
                             table      => 'cust_credit_bill_pkg',
                           },
@@ -4571,6 +4570,7 @@ sub tables_hashref {
         #'custnum',      'int', '', '', '', ''
         'billpkgnum',   'int', '', '', '', '', 
         'taxnum',       'int', '', '', '', '', 
+        'taxtype',  'varchar', 'NULL', $char_d, '', '',
         'year',         'int', 'NULL', '', '', '', 
         'month',        'int', 'NULL', '', '', '', 
         'creditbillpkgnum', 'int', 'NULL', '', '', '',
@@ -4586,7 +4586,7 @@ sub tables_hashref {
       'unique'       => [],
       'index'        => [ [ 'taxnum', 'year', 'month' ],
                           [ 'billpkgnum' ],
-                          [ 'taxnum' ],
+                          [ 'taxnum', 'taxtype' ],
                           [ 'creditbillpkgnum' ],
                         ],
       'foreign_keys' => [
diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm
index a146c54..0972fb7 100644
--- a/FS/FS/TaxEngine.pm
+++ b/FS/FS/TaxEngine.pm
@@ -14,22 +14,22 @@ FS::TaxEngine - Base class for tax calculation engines.
 =head1 USAGE
 
 1. At the start of creating an invoice, create an FS::TaxEngine object.
-2. Each time a sale item is added to the invoice, call C<add_sale> on the 
+2. Each time a sale item is added to the invoice, call L</add_sale> on the 
    TaxEngine.
-
-- If the TaxEngine is "batch" style (Billsoft):
 3. Set the "pending" flag on the invoice.
 4. Insert the invoice and its line items.
+
+- If the TaxEngine is "batch" style (Billsoft):
 5. After creating all invoices for the day, call 
    FS::TaxEngine::process_tax_batch.  This will create the tax items for
    all of the pending invoices, clear the "pending" flag, and call 
-   C<collect> on each of the billed customers.
+   L<FS::cust_main::Billing/collect> on each of the billed customers.
 
 - If not (the internal tax system, CCH):
-3. After adding all sale items, call C<calculate_taxes> on the TaxEngine to
+5. After adding all sale items, call L</calculate_taxes> on the TaxEngine to
    produce a list of tax line items.
-4. Append the tax line items to the invoice.
-5. Insert the invoice.
+6. Append the tax line items to the invoice.
+7. Update the invoice with the new charged amount and clear the pending flag.
 
 =head1 CLASS METHODS
 
@@ -48,15 +48,15 @@ indicate that the package is being billed on cancellation.
 sub new {
   my $class = shift;
   my %opt = @_;
+  my $conf = FS::Conf->new;
   if ($class eq 'FS::TaxEngine') {
-    my $conf = FS::Conf->new;
     my $subclass = $conf->config('enable_taxproducts') || 'internal';
     $class .= "::$subclass";
     local $@;
     eval "use $class";
     die "couldn't load $class: $@\n" if $@;
   }
-  my $self = { items => [], taxes => {}, %opt };
+  my $self = { items => [], taxes => {}, conf => $conf, %opt };
   bless $self, $class;
 }
 
@@ -84,33 +84,36 @@ Returns a hashref of metadata about this tax method, including:
 
 Adds the CUST_BILL_PKG object as a taxable sale on this invoice.
 
-=item calculate_taxes CUST_BILL
+=item calculate_taxes INVOICE
 
 Calculates the taxes on the taxable sales and returns a list of 
-L<FS::cust_bill_pkg> objects to add to the invoice.  There is a base 
-implementation of this, which calls the C<taxline> method to calculate
-each individual tax.
+L<FS::cust_bill_pkg> objects to add to the invoice.  The base implementation
+is to call L</make_taxlines> to produce a list of "raw" tax line items, 
+then L</consolidate_taxlines> to combine those with the same itemdesc.
 
 =cut
 
 sub calculate_taxes {
   my $self = shift;
-  my $conf = FS::Conf->new;
-
   my $cust_bill = shift;
 
-  my @tax_line_items;
-  # keys are tax names (as printed on invoices / itemdesc )
-  # values are arrayrefs of taxlines
-  my %taxname;
+  my @raw_taxlines = $self->make_taxlines($cust_bill);
 
-  # keys are taxnums
-  # values are (cumulative) amounts
-  my %tax_amount;
+  my @real_taxlines = $self->consolidate_taxlines(@raw_taxlines);
 
-  # keys are taxnums
-  # values are arrayrefs of cust_tax_exempt_pkg objects
-  my %tax_exemption;
+  if ( $cust_bill and $cust_bill->get('invnum') ) {
+    $_->set('invnum', $cust_bill->get('invnum')) foreach @real_taxlines;
+  }
+  return \@real_taxlines;
+}
+
+sub make_taxlines {
+  my $self = shift;
+  my $conf = $self->{conf};
+
+  my $cust_bill = shift;
+
+  my @taxlines;
 
   # For each distinct tax rate definition, calculate the tax and exemptions.
   foreach my $taxnum ( keys %{ $self->{taxes} } ) {
@@ -127,10 +130,35 @@ sub calculate_taxes {
     # with their link records
     die $taxline unless ref($taxline);
 
-    push @{ $taxname{ $taxline->itemdesc } }, $taxline;
+    push @taxlines, $taxline;
 
   } #foreach $taxnum
 
+  return @taxlines;
+}
+
+sub consolidate_taxlines {
+
+  my $self = shift;
+  my $conf = $self->{conf};
+
+  my @raw_taxlines = @_;
+  my @tax_line_items;
+
+  # keys are tax names (as printed on invoices / itemdesc )
+  # values are arrayrefs of taxlines
+  my %taxname;
+  # collate these by itemdesc
+  foreach my $taxline (@raw_taxlines) {
+    my $taxname = $taxline->itemdesc;
+    $taxname{$taxname} ||= [];
+    push @{ $taxname{$taxname} }, $taxline;
+  }
+
+  # keys are taxnums
+  # values are (cumulative) amounts
+  my %tax_amount;
+
   my $link_table = $self->info->{link_table};
   # For each distinct tax name (the values set as $taxline->itemdesc),
   # create a consolidated tax item with the total amount and all the links
@@ -138,7 +166,6 @@ sub calculate_taxes {
   foreach my $taxname ( keys %taxname ) {
     my @tax_links;
     my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({
-        'invnum'    => $cust_bill->invnum,
         'pkgnum'    => 0,
         'recur'     => 0,
         'sdate'     => '',
@@ -185,7 +212,7 @@ sub calculate_taxes {
     push @tax_line_items, $tax_cust_bill_pkg;
   }
 
-  \@tax_line_items;
+  @tax_line_items;
 }
 
 =head1 CLASS METHODS
diff --git a/FS/FS/TaxEngine/cch.pm b/FS/FS/TaxEngine/cch.pm
index 6bad69e..4e6dbaf 100644
--- a/FS/FS/TaxEngine/cch.pm
+++ b/FS/FS/TaxEngine/cch.pm
@@ -8,7 +8,7 @@ use FS::Conf;
 
 =head1 SUMMARY
 
-FS::TaxEngine::cch CCH published tax tables.  Uses multiple tables:
+FS::TaxEngine::cch - CCH published tax tables.  Uses multiple tables:
 - tax_rate: definition of specific taxes, based on tax class and geocode.
 - cust_tax_location: definition of geocodes, using zip+4 codes.
 - tax_class: definition of tax classes.
@@ -27,91 +27,74 @@ $DEBUG = 0;
 
 my %part_pkg_cache;
 
-sub add_sale {
-  my ($self, $cust_bill_pkg, %options) = @_;
+=item add_sale LINEITEM
 
-  my $part_item = $options{part_item} || $cust_bill_pkg->part_X;
-  my $location = $options{location} || $cust_bill_pkg->tax_location;
+Takes LINEITEM (a L<FS::cust_bill_pkg> object) and adds it to three internal
+data structures:
 
-  push @{ $self->{items} }, $cust_bill_pkg;
+- C<items>, an arrayref of all items on this invoice.
+- C<taxes>, a hashref of taxnum => arrayref containing the items that are
+  taxable under that tax definition.
+- C<taxclass>, a hashref of taxnum => arrayref containing the tax class
+  names parallel to the C<taxes> array for the same tax.
 
-  my $conf = FS::Conf->new;
+The item will appear on C<taxes> once for each tax class (setup, recur,
+or a usage class number) that's taxable under that class and appears on
+the item.
 
-  my @classes;
-  push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
-  # debatable
-  push @classes, 'setup' if ($cust_bill_pkg->setup && !$self->{cancel});
-  push @classes, 'recur' if ($cust_bill_pkg->recur && !$self->{cancel});
+C<add_sale> will also determine any exemptions that apply to the item
+and attach them to LINEITEM.
 
-  my %taxes_for_class;
-
-  my $exempt = $conf->exists('cust_class-tax_exempt')
-                  ? ( $self->cust_class ? $self->cust_class->tax : '' )
-                  : $self->{cust_main}->tax;
-  # standardize this just to be sure
-  $exempt = ($exempt eq 'Y') ? 'Y' : '';
-
-  if ( !$exempt ) {
+=cut
 
-    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_for_class{$class} = $err_or_ref;
-    }
-    unless (exists $taxes_for_class{''}) {
-      my $err_or_ref = $self->_gather_taxes( $part_item, '', $location );
-      return $err_or_ref unless ref($err_or_ref);
-      $taxes_for_class{''} = $err_or_ref;
-    }
+sub add_sale {
+  my ($self, $cust_bill_pkg) = @_;
 
-  }
+  my $part_item = $cust_bill_pkg->part_X;
+  my $location = $cust_bill_pkg->tax_location;
+  my $custnum = $self->{cust_main}->custnum;
 
-  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_for_class{$key} is an arrayref of tax_rate objects that
-    # apply to $key-class charges.
-    my @taxes = @{ $taxes_for_class{$key} || [] };
-    my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
+  push @{ $self->{items} }, $cust_bill_pkg;
 
-    my %localtaxlisthash = ();
-    foreach my $tax ( @taxes ) {
+  my $conf = FS::Conf->new;
 
-      my $taxnum = $tax->taxnum;
-      $self->{taxes}{$taxnum} ||= [ $tax ];
-      push @{ $self->{taxes}{$taxnum} }, $tax_cust_bill_pkg;
+  my @classes;
+  my $usage = $cust_bill_pkg->usage || 0;
+  push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
+  if (!$self->{cancel}) {
+    push @classes, 'setup' if $cust_bill_pkg->setup > 0;
+    push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) > 0;
+  }
 
-      $localtaxlisthash{ $taxnum } ||= [ $tax ];
-      push @{ $localtaxlisthash{$taxnum} }, $tax_cust_bill_pkg;
+  # About $self->{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.
 
-    }
+  # only calculate exemptions once for each tax rate, even if it's used for
+  # multiple classes.
+  my %tax_seen;
 
-    warn "finding taxed taxes...\n" if $DEBUG > 2;
-    foreach my $taxnum ( keys %localtaxlisthash ) {
-      my $tax_object = shift @{ $localtaxlisthash{$taxnum} };
+  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;
 
-      foreach my $tot ( $tax_object->tax_on_tax( $location ) ) {
-        my $totnum = $tot->taxnum;
+    next if !@taxes;
 
-        # I'm not sure why, but for some reason we only add ToT if that 
-        # tax_rate already applies to a non-tax item on the same invoice.
-        next unless exists( $localtaxlisthash{ $totnum } );
-        warn "adding #$totnum to taxed taxes\n" if $DEBUG > 2;
-        # calculate the tax amount that the tax_on_tax will apply to
-        my $taxline =
-          $self->taxline( 'tax' => $tax_object,
-                          'sales' => $localtaxlisthash{$taxnum}
-                        );
-        return $taxline unless ref $taxline;
-        # and append it to the list of taxable items
-        $self->{taxes}->{$totnum} ||= [ $tot ];
-        push @{ $self->{taxes}->{$totnum} }, $taxline->setup;
-
-      } # foreach $tot (tax-on-tax)
-    } # foreach $tax
-  } # foreach $key (i.e. usage class)
+    foreach my $tax (@taxes) {
+      my $taxnum = $tax->taxnum;
+      $self->{taxes}{$taxnum} ||= [];
+      $self->{taxclass}{$taxnum} ||= [];
+      push @{ $self->{taxes}{$taxnum} }, $cust_bill_pkg;
+      push @{ $self->{taxclass}{$taxnum} }, $class;
+
+      if ( !$tax_seen{$taxnum} ) {
+        $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum );
+        $tax_seen{$taxnum}++;
+      }
+    } #foreach $tax
+  } #foreach $class
 }
 
 sub _gather_taxes { # interface for this sucks
@@ -129,44 +112,95 @@ sub _gather_taxes { # interface for this sucks
    if $DEBUG;
 
   \@taxes;
-
 }
 
-sub taxline {
-  # FS::tax_rate::taxline() ridiculously returns a description and amount 
-  # instead of a real line item.  Fix that here.
-  #
-  # XXX eventually move the code from tax_rate to here
-  # but that's not necessary yet
-  my ($self, %opt) = @_;
-  my $tax_object = $opt{tax};
-  my $taxables = $opt{sales};
-  my $hashref = $tax_object->taxline_cch($taxables);
-  return $hashref unless ref $hashref; # it's an error message
-
-  my $tax_amount = sprintf('%.2f', $hashref->{amount});
-  my $tax_item = FS::cust_bill_pkg->new({
-      'itemdesc'  => $hashref->{name},
-      'pkgnum'    => 0,
-      'recur'     => 0,
-      'sdate'     => '',
-      'edate'     => '',
-      'setup'     => $tax_amount,
-  });
-  my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({
-      'taxnum'              => $tax_object->taxnum,
-      'taxtype'             => ref($tax_object), #redundant
-      'amount'              => $tax_amount,
-      'locationtaxid'       => $tax_object->location,
-      'taxratelocationnum'  =>
-          $tax_object->tax_rate_location->taxratelocationnum,
-      'tax_cust_bill_pkg'   => $tax_item,
-      # XXX still need to get taxable_cust_bill_pkg in here
-      # but that requires messing around in the taxline code
-  });
-  $tax_item->set('cust_bill_pkg_tax_rate_location', [ $tax_link ]);
-
-  return $tax_item;
+# differs from stock make_taxlines because we need another pass to do
+# tax on tax
+sub make_taxlines {
+  my $self = shift;
+  my $cust_bill = shift;
+
+  my @raw_taxlines;
+  my %taxable_location; # taxable billpkgnum => cust_location
+  my %item_has_tax; # taxable billpkgnum => taxnum
+  foreach my $taxnum ( keys %{ $self->{taxes} } ) {
+    my $tax_rate = FS::tax_rate->by_key($taxnum);
+    my $taxables = $self->{taxes}{$taxnum};
+    my $charge_classes = $self->{taxclass}{$taxnum};
+    foreach (@$taxables) {
+      $taxable_location{ $_->billpkgnum } ||= $_->tax_location;
+    }
+
+    my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes );
+
+    next if !@taxlines;
+    if (!ref $taxlines[0]) {
+      # it's an error string
+      warn "error evaluating tax#$taxnum\n";
+      return $taxlines[0];
+    }
+
+    my $billpkgnum = -1; # the current one
+    my $fragments; # $item_has_tax{$billpkgnum}{taxnum}
+
+    foreach my $taxline (@taxlines) {
+      next if $taxline->setup == 0;
+
+      my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0];
+      # store this tax fragment, indexed by taxable item, then by taxnum
+      if ( $billpkgnum != $link->taxable_billpkgnum ) {
+        $billpkgnum = $link->taxable_billpkgnum;
+        $item_has_tax{$billpkgnum} ||= {};
+        $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= [];
+      }
+
+      $taxline->set('invnum', $cust_bill->invnum);
+      push @$fragments, $taxline; # so we can ToT it
+      push @raw_taxlines, $taxline; # so we actually bill it
+    }
+  } # foreach $taxnum
+
+  # all first-tier taxes are calculated. now for tax on tax
+  # (has to be done on a per-taxable-item basis)
+  foreach my $billpkgnum (keys %item_has_tax) {
+    # taxes that apply to this item
+    my $this_has_tax = $item_has_tax{$billpkgnum};
+    my $location = $taxable_location{$billpkgnum};
+    foreach my $taxnum (keys %$this_has_tax) {
+      my $tax_rate = FS::tax_rate->by_key($taxnum);
+      # find all taxes that apply to it in this location
+      my @tot = $tax_rate->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 $totnum = $tot->taxnum;
+        warn "checking taxnum ".$tot->taxnum. 
+             " which we call ". $tot->taxname ."\n"
+          if $DEBUG > 2;
+        if ( exists $this_has_tax->{ $totnum } ) {
+          warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
+            if $DEBUG; 
+          my @taxlines = $tot->taxline_cch(
+            $this_has_tax->{ $taxnum }, # the first-stage tax (in an arrayref)
+          );
+          next if (!@taxlines); # it didn't apply after all
+          if (!ref($taxlines[0])) {
+            warn "error evaluating TOT ($totnum on $taxnum)\n";
+            return $taxlines[0];
+          }
+          # add these to the taxline queue
+          push @raw_taxlines, @taxlines;
+        } # if $this_has_tax->{$totnum}
+      } # foreach my $tot (tax-on-tax rate definition)
+    } # foreach $taxnum (first-tier rate definition)
+  } # foreach $taxable_item
+
+  return @raw_taxlines;
 }
 
 sub cust_tax_locations {
diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm
index 60f7aad..3b13510 100644
--- a/FS/FS/TaxEngine/internal.pm
+++ b/FS/FS/TaxEngine/internal.pm
@@ -15,10 +15,11 @@ my %part_pkg_cache;
 
 sub add_sale {
   my ($self, $cust_bill_pkg) = @_;
-  my $cust_pkg = $cust_bill_pkg->cust_pkg;
-  my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
-  my $part_pkg = $part_pkg_cache{$pkgpart} ||= FS::part_pkg->by_key($pkgpart)
-    or die "pkgpart $pkgpart not found";
+
+  my $part_item = $cust_bill_pkg->part_X;
+  my $location = $cust_bill_pkg->tax_location;
+  my $custnum = $self->{cust_main}->custnum;
+
   push @{ $self->{items} }, $cust_bill_pkg;
 
   my $location = $cust_pkg->tax_location; # cacheable?
@@ -46,9 +47,10 @@ sub add_sale {
     $taxhash_elim{ shift(@elim) } = '';
   } while ( !scalar(@taxes) && scalar(@elim) );
 
-  foreach (@taxes) {
-    my $taxnum = $_->taxnum;
-    $self->{taxes}->{$taxnum} ||= [ $_ ];
+  foreach my $tax (@taxes) {
+    my $taxnum = $tax->taxnum;
+    $self->{taxes}->{$taxnum} ||= [ $tax ];
+    $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum );
     push @{ $self->{taxes}->{$taxnum} }, $cust_bill_pkg;
   }
 }
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index aa25f8c..156ab5b 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -202,10 +202,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,
@@ -221,8 +224,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', '');
@@ -246,18 +249,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 ) {
@@ -556,6 +559,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.
@@ -810,71 +945,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
@@ -949,7 +1060,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_tax_Xlocation
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index 9bfab96..8f62348 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;
@@ -1389,6 +1390,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 {
@@ -1418,85 +1424,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
 
-        }
       }
     }
 
@@ -1536,6 +1530,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/tax_rate.pm b/FS/FS/tax_rate.pm
index 0047f9d..8579020 100644
--- a/FS/FS/tax_rate.pm
+++ b/FS/FS/tax_rate.pm
@@ -18,6 +18,7 @@ use HTTP::Response;
 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;
@@ -379,57 +380,66 @@ sub passtype_name {
   $tax_passtypes{$self->passtype};
 }
 
-=item taxline_cch TAXABLES, [ OPTIONSHASH ]
+=item taxline_cch TAXABLES, CLASSES
 
-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.
+Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable line
+items, and an arrayref of charge classes ('setup', 'recur', '' for 
+unclassified usage, or an L<FS::usage_class> number). Calculates the tax on
+each item under this tax definition and returns a list of new 
+L<FS::cust_bill_pkg> objects for the taxes charged. 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 linking the tax rate
+back to this tax, and to its originating sale.
+
+If the taxable objects are linked to an invoice, this will also calculate
+per-customer exemptions (cust_exempt and cust_taxname_exempt) and attach them
+to the line items in the 'cust_tax_exempt_pkg' pseudo-field.
+
+For accurate calculation of per-customer or per-location taxes, ALL items
+appearing on the invoice (and subject to this tax) MUST be passed to this
+method together, and NO items from any other invoice should be included.
 
 =cut
 
+# future optimization: it would probably suffice to return only the link
+# records, and let the consolidation routine build the cust_bill_pkgs
+
 sub taxline_cch {
   my $self = shift;
   # this used to accept a hash of options but none of them did anything
   # so it's been removed.
 
-  my $taxables;
-
-  if (ref($_[0]) eq 'ARRAY') {
-    $taxables = shift;
-  }else{
-    $taxables = [ @_ ];
-    #exemptions would be broken in this case
-  }
+  my $taxables = shift;
+  my $classes = shift || [];
 
   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;
+  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?
+
+  # in 4.x, the invoice is _already inserted_ before we try to calculate
+  # tax on it. though it may be a quotation, so be careful.
+
+  my $cust_main;
+  my $cust_bill = $taxables->[0]->cust_bill;
+  $cust_main = $cust_bill->cust_main if $cust_bill;
 
   my $taxable_charged = 0;
   my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
                       @$taxables;
 
+  my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum;
+
   warn "calculating taxes for ". $self->taxnum. " on ".
     join (",", map { $_->pkgnum } @cust_bill_pkg)
     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
@@ -451,54 +461,144 @@ sub taxline_cch {
       $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) {
-
-    if (( $self->unittype || 0 ) == 0) { #access line
-      my %seen = ();
-      foreach (@cust_bill_pkg) {
-        $taxable_units += $_->units
-          unless $seen{$_->pkgnum}++;
+  my $tax_cents = 0;
+
+  while (@$taxables) {
+    my $cust_bill_pkg = shift @$taxables;
+    my $class = shift @$classes;
+    $class = 'all' if !defined($class);
+
+    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 };
+
+    if ( $self->tax > 0 ) {
+
+      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;
       }
 
-    } elsif ($self->unittype == 1) { #minute
-      return $self->_fatal_or_null( 'fee with minute unit type' );
-
-    } elsif ($self->unittype == 2) { #account
+      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;
+        }
 
-      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}++;
+        $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_billpkgnum'    => $cust_bill_pkg->billpkgnum,
+          '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
+        my $locationnum = $cust_bill_pkg->tax_locationnum;
+        if (!$locationnum and $cust_main) {
+          $locationnum = $cust_main->ship_locationnum;
         }
+        # the other case is that it's a quotation
+                        
+        $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
 
-  }
+  # check bracket maxima; throw an error if we've gone over, because
+  # we don't really implement them
 
-  # 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
-
-  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
@@ -507,17 +607,42 @@ sub taxline_cch {
     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             |   10 +-
 FS/FS/TaxEngine.pm          |   83 ++++++++-----
 FS/FS/TaxEngine/cch.pm      |  248 ++++++++++++++++++++++-----------------
 FS/FS/TaxEngine/internal.pm |   16 +--
 FS/FS/cust_bill_pkg.pm      |  259 +++++++++++++++++++++++++++++------------
 FS/FS/cust_main/Billing.pm  |  131 ++++++++++-----------
 FS/FS/tax_rate.pm           |  271 +++++++++++++++++++++++++++++++------------
 7 files changed, 656 insertions(+), 362 deletions(-)




More information about the freeside-commits mailing list