CCH tax exemptions + 4.x tax system, #34223
authorMark Wells <mark@freeside.biz>
Wed, 1 Apr 2015 06:54:21 +0000 (01:54 -0500)
committerMark Wells <mark@freeside.biz>
Wed, 1 Apr 2015 06:54:21 +0000 (01:54 -0500)
FS/FS/Schema.pm
FS/FS/TaxEngine.pm
FS/FS/TaxEngine/cch.pm
FS/FS/TaxEngine/internal.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_main/Billing.pm
FS/FS/tax_rate.pm

index c0dd2b4..4bc3598 100644 (file)
@@ -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' => [
index a146c54..0972fb7 100644 (file)
@@ -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
index 6bad69e..4e6dbaf 100644 (file)
@@ -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 {
index 60f7aad..3b13510 100644 (file)
@@ -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;
   }
 }
index aa25f8c..156ab5b 100644 (file)
@@ -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
index 9bfab96..8f62348 100644 (file)
@@ -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;
     }
 
index 0047f9d..8579020 100644 (file)
@@ -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 {