RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / tax_rate.pm
index 334c31e..8bc0c6e 100644 (file)
@@ -1,22 +1,24 @@
 package FS::tax_rate;
 package FS::tax_rate;
+use base qw( FS::Record );
 
 use strict;
 
 use strict;
-use vars qw( @ISA $DEBUG $me
+use vars qw( $DEBUG $me
              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
              %tax_passtypes %GetInfoType $keep_cch_files );
 use Date::Parse;
 use DateTime;
 use DateTime::Format::Strptime;
              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
              %tax_passtypes %GetInfoType $keep_cch_files );
 use Date::Parse;
 use DateTime;
 use DateTime::Format::Strptime;
-use Storable qw( thaw nfreeze );
 use IO::File;
 use File::Temp;
 use IO::File;
 use File::Temp;
+use Text::CSV_XS;
+use URI::Escape;
 use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Response;
 use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Response;
-use MIME::Base64;
 use DBIx::DBSchema;
 use DBIx::DBSchema::Table;
 use DBIx::DBSchema::Column;
 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;
 use FS::Record qw( qsearch qsearchs dbh dbdef );
 use FS::Conf;
 use FS::tax_class;
@@ -28,10 +30,6 @@ use FS::part_pkg_taxproduct;
 use FS::cust_main;
 use FS::Misc qw( csv_from_fixed );
 
 use FS::cust_main;
 use FS::Misc qw( csv_from_fixed );
 
-use URI::Escape;
-
-@ISA = qw( FS::Record );
-
 $DEBUG = 0;
 $me = '[FS::tax_rate]';
 $keep_cch_files = 0;
 $DEBUG = 0;
 $me = '[FS::tax_rate]';
 $keep_cch_files = 0;
@@ -81,9 +79,10 @@ a location code provided by a tax authority
 
 =item taxclassnum
 
 
 =item taxclassnum
 
-a foreign key into FS::tax_class - the type of tax
-referenced but FS::part_pkg_taxrate
-eitem effective_date
+a foreign key into FS::tax_class - the type of tax referenced by 
+FS::part_pkg_taxrate
+
+=item effective_date
 
 the time after which the tax applies
 
 
 the time after which the tax applies
 
@@ -214,8 +213,8 @@ sub check {
   $self->ut_numbern('taxnum')
     || $self->ut_text('geocode')
     || $self->ut_textn('data_vendor')
   $self->ut_numbern('taxnum')
     || $self->ut_text('geocode')
     || $self->ut_textn('data_vendor')
-    || $self->ut_textn('location')
-    || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
+    || $self->ut_cch_textn('location')
+    || $self->ut_foreign_keyn('taxclassnum', 'tax_class', 'taxclassnum')
     || $self->ut_snumbern('effective_date')
     || $self->ut_float('tax')
     || $self->ut_floatn('excessrate')
     || $self->ut_snumbern('effective_date')
     || $self->ut_float('tax')
     || $self->ut_floatn('excessrate')
@@ -244,6 +243,18 @@ sub check {
 
 }
 
 
 }
 
+#ut_text / ut_textn w/ ` added cause now that's in the data
+sub ut_cch_textn {
+  my($self,$field)=@_;
+  $self->getfield($field)
+    =~ /^([\wô \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>\`]*)$/
+      or return gettext('illegal_or_empty_text'). " $field: ".
+                 $self->getfield($field);
+  $self->setfield($field,$1);
+  '';
+
+}
+
 =item taxclass_description
 
 Returns the human understandable value associated with the related
 =item taxclass_description
 
 Returns the human understandable value associated with the related
@@ -275,16 +286,25 @@ sub unittype_name {
 
 =item maxtype_name
 
 
 =item maxtype_name
 
-Returns the human understandable value associated with the maxtype column
+Returns the human understandable value associated with the maxtype column.
 
 =cut
 
 
 =cut
 
+# XXX these are non-functional, and most of them are horrible to implement
+# in our current model
+
 %tax_maxtypes = ( '0' => 'receipts per invoice',
                   '1' => 'receipts per item',
                   '2' => 'total utility charges per utility tax year',
                   '3' => 'total charges per utility tax year',
                   '4' => 'receipts per access line',
 %tax_maxtypes = ( '0' => 'receipts per invoice',
                   '1' => 'receipts per item',
                   '2' => 'total utility charges per utility tax year',
                   '3' => 'total charges per utility tax year',
                   '4' => 'receipts per access line',
+                  '7' => 'total utility charges per calendar year',
                   '9' => 'monthly receipts per location',
                   '9' => 'monthly receipts per location',
+                  '10' => 'monthly receipts exceeds taxbase and total tax per month does not exceed maxtax', # wtf?
+                  '11' => 'receipts/units per access line',
+                  '14' => 'units per invoice',
+                  '15' => 'units per month',
+                  '18' => 'units per account',
 );
 
 sub maxtype_name {
 );
 
 sub maxtype_name {
@@ -360,69 +380,69 @@ sub passtype_name {
   $tax_passtypes{$self->passtype};
 }
 
   $tax_passtypes{$self->passtype};
 }
 
-=item taxline TAXABLES, [ OPTIONSHASH ]
+=item taxline_cch TAXABLES, CLASSES
+
+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_tax_rate_location> objects for the taxes charged.
+
+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.
 
 
-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.
+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
 
 
 =cut
 
-sub taxline {
+sub taxline_cch {
   my $self = shift;
   my $self = shift;
+  # this used to accept a hash of options but none of them did anything
+  # so it's been removed.
 
 
-  my $taxables;
-  my %opt = ();
-
-  if (ref($_[0]) eq 'ARRAY') {
-    $taxables = shift;
-    %opt = @_;
-  }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;
 
   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 $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;
 
   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;
   my $maxtype = $self->maxtype || 0;
-  if ($maxtype != 0 && $maxtype != 9) {
+  if ($maxtype != 0 && $maxtype != 1 
+      && $maxtype != 14 && $maxtype != 15
+      && $maxtype != 18 # sigh
+    ) {
     return $self->_fatal_or_null( 'tax with "'.
                                     $self->maxtype_name. '" threshold'
                                 );
     return $self->_fatal_or_null( 'tax with "'.
                                     $self->maxtype_name. '" threshold'
                                 );
-  }
-
-  if ($maxtype == 9) {
-    return
-      $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' );
-                                                                # "texas" tax
-  }
+  } # I don't know why, it's not like there are maxtypes that we DO support
 
   # we treat gross revenue as gross receipts and expect the tax data
   # to DTRT (i.e. tax on tax rules)
 
   # we treat gross revenue as gross receipts and expect the tax data
   # to DTRT (i.e. tax on tax rules)
@@ -435,69 +455,178 @@ sub taxline {
       $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
   }
 
       $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_links; # for output
+  my %seen; # locationnum or pkgnum => 1
 
 
+  my $taxable_cents = 0;
   my $taxable_units = 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_link = 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_links, $tax_link;
+
+      $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 {
       } 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_link = FS::cust_bill_pkg_tax_rate_location->new({
+          'taxnum'                => $self->taxnum,
+          'taxtype'               => ref($self),
+          'cents'                 => $this_tax_cents,
+          'locationtaxid'         => $self->location,
+          'taxable_billpkgnum'    => $cust_bill_pkg->billpkgnum,
+          'taxable_cust_bill_pkg' => $cust_bill_pkg,
+          'taxratelocationnum'    => $taxratelocationnum,
+          'taxclass'              => $class,
+      });
+      push @tax_links, $tax_link;
+
+      $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
+
+  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
+    # it's way more complicated than that. this won't even catch every case
+    # where a bracket maximum should apply.)
+    return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum );
   }
 
   }
 
-  #
-  # XXX insert exemption handling here
-  #
-  # the tax or fee is applied to taxbase or feebase and then
-  # the excessrate or excess fee is applied to taxmax or feemax
-  #
-
-  $amount += $taxable_charged * $self->tax;
-  $amount += $taxable_units * $self->fee;
-  
-  warn "calculated taxes as [ $name, $amount ]\n"
-    if $DEBUG;
-
-  return {
-    'name'   => $name,
-    'amount' => $amount,
-  };
+  # 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_links) { # 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 @tax_links;
 }
 
 sub _fatal_or_null {
   my ($self, $error) = @_;
 
 }
 
 sub _fatal_or_null {
   my ($self, $error) = @_;
 
+  $DB::single = 1; # not a mistake
+
   my $conf = new FS::Conf;
 
   $error = "can't yet handle ". $error;
   my $conf = new FS::Conf;
 
   $error = "can't yet handle ". $error;
@@ -513,10 +642,10 @@ sub _fatal_or_null {
   }
 }
 
   }
 }
 
-=item tax_on_tax CUST_MAIN
+=item tax_on_tax CUST_LOCATION
 
 Returns a list of taxes which are candidates for taxing taxes for the
 
 Returns a list of taxes which are candidates for taxing taxes for the
-given customer (see L<FS::cust_main>)
+given service location (see L<FS::cust_location>)
 
 =cut
 
 
 =cut
 
@@ -524,13 +653,13 @@ given customer (see L<FS::cust_main>)
 sub tax_on_tax {
        #akshun
   my $self = shift;
 sub tax_on_tax {
        #akshun
   my $self = shift;
-  my $cust_main = shift;
+  my $cust_location = shift;
 
   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
 
   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
-    $cust_main->custnum
+    $cust_location->custnum
     if $DEBUG;
 
     if $DEBUG;
 
-  my $geocode = $cust_main->geocode($self->data_vendor);
+  my $geocode = $cust_location->geocode($self->data_vendor);
 
   # CCH oddness in m2m
   my $dbh = dbh;
 
   # CCH oddness in m2m
   my $dbh = dbh;
@@ -560,7 +689,9 @@ sub tax_on_tax {
     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
 
   qsearch({ 'table'     => 'tax_rate',
     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
 
   qsearch({ 'table'     => 'tax_rate',
-            'hashref'   => { 'geocode' => $geocode, },
+            'hashref'   => { 'data_vendor' => $self->data_vendor,
+                             'geocode'     => $geocode,
+                           },
             'extra_sql' => $extra_sql,
          })
 
             'extra_sql' => $extra_sql,
          })
 
@@ -586,6 +717,36 @@ sub tax_rate_location {
 
 }
 
 
 }
 
+
+=item find_or_insert
+
+Finds an existing tax definition matching the data_vendor, taxname,
+taxclassnum, and geocode of this one, if one exists, and sets the contents of
+this tax rate equal to that one (including its taxnum). If an existing
+definition is not found, inserts this one. Returns an error string if
+inserting a record failed.
+
+=cut
+
+sub find_or_insert {
+  my $self = shift;
+  # this doesn't uniquely identify CCH taxes (kinda goofy, I know)
+  die "find_or_insert is not compatible with CCH taxes\n"
+    if $self->data_vendor eq 'cch';
+
+  my @keys = (qw(data_vendor taxname taxclassnum geocode));
+  my %hash = map { $_ => $self->get($_) } @keys;
+  my $existing = qsearchs('tax_rate', \%hash);
+  if ($existing) {
+    foreach ($self->fields) {
+      $self->set($_, $existing->get($_));
+    }
+    return;
+  } else {
+    return $self->insert;
+  }
+}
+
 =back
 
 =head1 SUBROUTINES
 =back
 
 =head1 SUBROUTINES
@@ -637,6 +798,7 @@ sub batch_import {
   $count *=2;
 
   if ( $format eq 'cch' || $format eq 'cch-update' ) {
   $count *=2;
 
   if ( $format eq 'cch' || $format eq 'cch-update' ) {
+    #false laziness w/below (sub _perform_cch_diff)
     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
                   excessrate effective_date taxauth taxtype taxcat taxname
                   usetax useexcessrate fee unittype feemax maxtype passflag
     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
                   excessrate effective_date taxauth taxtype taxcat taxname
                   usetax useexcessrate fee unittype feemax maxtype passflag
@@ -715,9 +877,6 @@ sub batch_import {
     die "unknown format $format";
   }
 
     die "unknown format $format";
   }
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
-
   my $csv = new Text::CSV_XS;
 
   my $imported = 0;
   my $csv = new Text::CSV_XS;
 
   my $imported = 0;
@@ -758,9 +917,10 @@ sub batch_import {
     foreach my $field ( @fields ) {
       $tax_rate{$field} = shift @columns; 
     }
     foreach my $field ( @fields ) {
       $tax_rate{$field} = shift @columns; 
     }
+
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
-      return "Unexpected trailing columns in line (wrong format?): $line";
+      return "Unexpected trailing columns in line (wrong format?) importing tax_rate: $line";
     }
 
     my $error = &{$hook}(\%tax_rate);
     }
 
     my $error = &{$hook}(\%tax_rate);
@@ -785,7 +945,8 @@ sub batch_import {
 
   }
 
 
   }
 
-  for (grep { !exists($delete{$_}) } keys %insert) {
+  my @replace = grep { exists($delete{$_}) } keys %insert;
+  for (@replace) {
     if ( $job ) {  # progress bar
       if ( time - $min_sec > $last ) {
         my $error = $job->update_statustext(
     if ( $job ) {  # progress bar
       if ( time - $min_sec > $last ) {
         my $error = $job->update_statustext(
@@ -799,20 +960,35 @@ sub batch_import {
       }
     }
 
       }
     }
 
-    my $tax_rate = new FS::tax_rate( $insert{$_} );
-    my $error = $tax_rate->insert;
+    my $old = qsearchs( 'tax_rate', $delete{$_} );
 
 
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      my $hashref = $insert{$_};
-      $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
-      return "can't insert tax_rate for $line: $error";
+    if ( $old ) {
+
+      my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
+      $new->taxnum($old->taxnum);
+      my $error = $new->replace($old);
+
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        my $hashref = $insert{$_};
+        $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
+        return "can't replace tax_rate for $line: $error";
+      }
+
+      $imported++;
+
+    } else {
+
+      $old = delete $delete{$_};
+      warn "WARNING: can't find tax_rate to replace (inserting instead and continuing) for: ".
+        #join(" ", map { "$_ => ". $old->{$_} } @fields);
+        join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
     }
 
     $imported++;
   }
 
     }
 
     $imported++;
   }
 
-  for (grep { exists($delete{$_}) } keys %insert) {
+  for (grep { !exists($delete{$_}) } keys %insert) {
     if ( $job ) {  # progress bar
       if ( time - $min_sec > $last ) {
         my $error = $job->update_statustext(
     if ( $job ) {  # progress bar
       if ( time - $min_sec > $last ) {
         my $error = $job->update_statustext(
@@ -826,27 +1002,17 @@ sub batch_import {
       }
     }
 
       }
     }
 
-    my $old = qsearchs( 'tax_rate', $delete{$_} );
-    unless ($old) {
-      $dbh->rollback if $oldAutoCommit;
-      $old = $delete{$_};
-      return "can't find tax_rate to replace for: ".
-        #join(" ", map { "$_ => ". $old->{$_} } @fields);
-        join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
-    }
-    my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
-    $new->taxnum($old->taxnum);
-    my $error = $new->replace($old);
+    my $tax_rate = new FS::tax_rate( $insert{$_} );
+    my $error = $tax_rate->insert;
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       my $hashref = $insert{$_};
       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       my $hashref = $insert{$_};
       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
-      return "can't replace tax_rate for $line: $error";
+      return "can't insert tax_rate for $line: $error";
     }
 
     $imported++;
     }
 
     $imported++;
-    $imported++;
   }
 
   for (grep { !exists($insert{$_}) } keys %delete) {
   }
 
   for (grep { !exists($insert{$_}) } keys %delete) {
@@ -864,20 +1030,22 @@ sub batch_import {
     }
 
     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
     }
 
     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
-    unless ($tax_rate) {
+    if (!$tax_rate) {
       $dbh->rollback if $oldAutoCommit;
       $tax_rate = $delete{$_};
       $dbh->rollback if $oldAutoCommit;
       $tax_rate = $delete{$_};
-      return "can't find tax_rate to delete for: ".
-        #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
-        join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
-    }
-    my $error = $tax_rate->delete;
+      warn "WARNING: can't find tax_rate to delete for: ".
+        join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) ).
+        " (ignoring)\n";
+    } else {
+      my $error = $tax_rate->delete; #  XXX we really should not do this
+                                     # (it orphans CBPTRL records)
 
 
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      my $hashref = $delete{$_};
-      $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
-      return "can't delete tax_rate for $line: $error";
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        my $hashref = $delete{$_};
+        $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
+        return "can't delete tax_rate for $line: $error";
+      }
     }
 
     $imported++;
     }
 
     $imported++;
@@ -898,35 +1066,25 @@ Load a batch import as a queued JSRPC job
 =cut
 
 sub process_batch_import {
 =cut
 
 sub process_batch_import {
-  my $job = shift;
+  my ($job, $param) = @_;
 
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  my $param = thaw(decode_base64(shift));
-  my $args = '$job, encode_base64( nfreeze( $param ) )';
-
-  my $method = '_perform_batch_import';
   if ( $param->{reload} ) {
   if ( $param->{reload} ) {
-    $method = 'process_batch_reload';
-  }
-
-  eval "$method($args);";
-  if ($@) {
-    $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-    die $@;
+    process_batch_reload($job, $param);
+  } else {
+    # '_perform', yuck
+    _perform_batch_import($job, $param);
   }
 
   }
 
-  #success!
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 }
 
 sub _perform_batch_import {
 }
 
 sub _perform_batch_import {
-  my $job = shift;
+  my ($job, $param) = @_;
 
 
-  my $param = thaw(decode_base64(shift));
-  my $format = $param->{'format'};        #well... this is all cch specific
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  
+  my $format = $param->{'format'};
 
   my $files = $param->{'uploaded_files'}
     or die "No files provided.";
 
   my $files = $param->{'uploaded_files'}
     or die "No files provided.";
@@ -934,20 +1092,18 @@ sub _perform_batch_import {
   my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
                 split /,/, $files;
 
   my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
                 split /,/, $files;
 
+  my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
+  my $error = '';
+
   if ( $format eq 'cch' || $format eq 'cch-fixed'
     || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
   {
 
   if ( $format eq 'cch' || $format eq 'cch-fixed'
     || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
   {
 
-    my $oldAutoCommit = $FS::UID::AutoCommit;
-    local $FS::UID::AutoCommit = 0;
-    my $dbh = dbh;
-    my $error = '';
     my @insert_list = ();
     my @delete_list = ();
     my @predelete_list = ();
     my $insertname = '';
     my $deletename = '';
     my @insert_list = ();
     my @delete_list = ();
     my @predelete_list = ();
     my $insertname = '';
     my $deletename = '';
-    my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
 
     my @list = ( 'GEOCODE',  \&FS::tax_rate_location::batch_import,
                  'CODE',     \&FS::tax_class::batch_import,
 
     my @list = ( 'GEOCODE',  \&FS::tax_rate_location::batch_import,
                  'CODE',     \&FS::tax_class::batch_import,
@@ -961,7 +1117,7 @@ sub _perform_batch_import {
       my $file = lc($name). 'file';
 
       unless ($files{$file}) {
       my $file = lc($name). 'file';
 
       unless ($files{$file}) {
-        $error = "No $name supplied";
+        #$error = "No $name supplied";
         next;
       }
       next if $name eq 'DETAIL' && $format =~ /update/;
         next;
       }
       next if $name eq 'DETAIL' && $format =~ /update/;
@@ -978,7 +1134,7 @@ sub _perform_batch_import {
         unlink $filename or warn "Can't delete $filename: $!"
           unless $keep_cch_files;
         push @insert_list, $name, $insertname, $import_sub, $format;
         unlink $filename or warn "Can't delete $filename: $!"
           unless $keep_cch_files;
         push @insert_list, $name, $insertname, $import_sub, $format;
-        if ( $name eq 'GEOCODE' ) { #handle this whole ordering issue better
+        if ( $name eq 'GEOCODE' || $name eq 'CODE' ) { #handle this whole ordering issue better
           unshift @predelete_list, $name, $deletename, $import_sub, $format;
         } else {
           unshift @delete_list, $name, $deletename, $import_sub, $format;
           unshift @predelete_list, $name, $deletename, $import_sub, $format;
         } else {
           unshift @delete_list, $name, $deletename, $import_sub, $format;
@@ -993,13 +1149,20 @@ sub _perform_batch_import {
     }
 
     push @insert_list,
     }
 
     push @insert_list,
-      'DETAIL', "$dir/".$files{detail}, \&FS::tax_rate::batch_import, $format
+      'DETAIL', "$dir/".$files{detailfile}, \&FS::tax_rate::batch_import, $format
       if $format =~ /update/;
 
       if $format =~ /update/;
 
+    my %addl_param = ();
+    if ( $param->{'delete_only'} ) {
+      $addl_param{'delete_only'} = $param->{'delete_only'};
+      @insert_list = () 
+    }
+
     $error ||= _perform_cch_tax_import( $job,
                                         [ @predelete_list ],
                                         [ @insert_list ],
                                         [ @delete_list ],
     $error ||= _perform_cch_tax_import( $job,
                                         [ @predelete_list ],
                                         [ @insert_list ],
                                         [ @delete_list ],
+                                        \%addl_param,
     );
     
     
     );
     
     
@@ -1009,22 +1172,49 @@ sub _perform_batch_import {
       unlink $file or warn "Can't delete $file: $!";
     }
 
       unlink $file or warn "Can't delete $file: $!";
     }
 
-    if ($error) {
-      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-      die $error;
-    }else{
-      $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  } elsif ( $format =~ /^billsoft-(\w+)$/ ) {
+    my $mode = $1;
+    my $file = $dir.'/'.$files{'file'};
+    open my $fh, "< $file" or $error ||= "Can't open file $file: $!";
+    my @param = (
+        {
+          filehandle  => $fh,
+          format      => 'billsoft',
+        }, $job);
+    if ( $mode eq 'pcode' ) {
+      $error ||= FS::cust_tax_location::batch_import(@param);
+      seek $fh, 0, 0;
+      $error ||= FS::tax_rate_location::batch_import(@param);
+    } elsif ( $mode eq 'taxclass' ) {
+      $error ||= FS::tax_class::batch_import(@param);
+    } elsif ( $mode eq 'taxproduct' ) {
+      $error ||= FS::part_pkg_taxproduct::batch_import(@param);
+    } else {
+      die "unknown import mode 'billsoft-$mode'\n";
     }
 
     }
 
-  }else{
+  } else {
     die "Unknown format: $format";
   }
 
     die "Unknown format: $format";
   }
 
+  if ($error) {
+    $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+    die $error;
+  } else {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  }
+
 }
 
 }
 
+#
+#
+# EVERYTHING THAT FOLLOWS IS CCH-SPECIFIC.
+#
+#
 
 sub _perform_cch_tax_import {
 
 sub _perform_cch_tax_import {
-  my ( $job, $predelete_list, $insert_list, $delete_list ) = @_;
+  my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
+  $addl_param ||= {};
 
   my $error = '';
   foreach my $list ($predelete_list, $insert_list, $delete_list) {
 
   my $error = '';
   foreach my $list ($predelete_list, $insert_list, $delete_list) {
@@ -1033,7 +1223,11 @@ sub _perform_cch_tax_import {
       my $fmt = "$format-update";
       $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
       my $fmt = "$format-update";
       $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
-      $error ||= &{$method}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
+      my $param = { 'filehandle' => $fh,
+                    'format'     => $fmt,
+                    %$addl_param,
+                  };
+      $error ||= &{$method}($param, $job);
       close $fh;
     }
   }
       close $fh;
     }
   }
@@ -1115,8 +1309,26 @@ sub _perform_cch_diff {
   }
   close $newcsvfh;
 
   }
   close $newcsvfh;
 
-  for (keys %oldlines) {
-    print $dfh $_, ',"D"', "\n" if $oldlines{$_};
+  #false laziness w/above (sub batch_import)
+  my @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
+                   excessrate effective_date taxauth taxtype taxcat taxname
+                   usetax useexcessrate fee unittype feemax maxtype passflag
+                   passtype basetype );
+  my $numfields = scalar(@fields);
+
+  my $csv = new Text::CSV_XS { 'always_quote' => 1 };
+
+  for my $line (grep $oldlines{$_}, keys %oldlines) {
+
+    $csv->parse($line) or do {
+      #$dbh->rollback if $oldAutoCommit;
+      die "can't parse: ". $csv->error_input();
+    };
+    my @columns = $csv->fields();
+    
+    $csv->combine( splice(@columns, 0, $numfields) );
+
+    print $dfh $csv->string, ',"D"', "\n";
   }
 
   close $dfh;
   }
 
   close $dfh;
@@ -1170,9 +1382,6 @@ sub _cch_fetch_and_unzip {
 sub _cch_extract_csv_from_dbf {
   my ( $job, $dir, $name ) = @_;
 
 sub _cch_extract_csv_from_dbf {
   my ( $job, $dir, $name ) = @_;
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
-
   eval "use XBase;";
   die $@ if $@;
 
   eval "use XBase;";
   die $@ if $@;
 
@@ -1196,9 +1405,14 @@ sub _cch_extract_csv_from_dbf {
           $date;
         };
   while (my $row = $cursor->fetch_hashref) {
           $date;
         };
   while (my $row = $cursor->fetch_hashref) {
-    $csv->combine( map { ($table->field_type($_) eq 'D')
-                         ? &{$format_date}($row->{$_}) 
-                         : $row->{$_}
+    $csv->combine( map { my $type = $table->field_type($_);
+                         if ($type eq 'D') {
+                           &{$format_date}($row->{$_}) ;
+                         } elsif ($type eq 'N' && $row->{$_} =~ /e-/i ) {
+                           sprintf('%.8f', $row->{$_}); #db row is numeric(14,8)
+                         } else {
+                           $row->{$_};
+                         }
                        }
                    @fields
     );
                        }
                    @fields
     );
@@ -1256,11 +1470,14 @@ sub _remember_tax_products {
 
   my ( $imported, $last, $min_sec ) = _progressbar_foo();
 
 
   my ( $imported, $last, $min_sec ) = _progressbar_foo();
 
-  my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
-                  "0 < ( SELECT count(*) from part_pkg_option WHERE ".
-                  "       part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
-                  "       optionname LIKE 'usage_taxproductnum_%' AND ".
-                  "       optionvalue != '' )";
+  my $extra_sql = "
+    WHERE taxproductnum IS NOT NULL
+       OR EXISTS ( SELECT 1 from part_pkg_option
+                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+                      AND optionname LIKE 'usage_taxproductnum_%'
+                      AND optionvalue != ''
+                 )
+  ";
   my @items = qsearch( { table => 'part_pkg',
                          select  => 'DISTINCT pkgpart,taxproductnum',
                          hashref => {},
   my @items = qsearch( { table => 'part_pkg',
                          select  => 'DISTINCT pkgpart,taxproductnum',
                          hashref => {},
@@ -1482,15 +1699,20 @@ sub _copy_from_temp {
 =item process_download_and_reload
 
 Download and process a tax update as a queued JSRPC job after wiping the
 =item process_download_and_reload
 
 Download and process a tax update as a queued JSRPC job after wiping the
-existing wipable tax data.
+existing wipeable tax data.
 
 =cut
 
 sub process_download_and_reload {
 
 =cut
 
 sub process_download_and_reload {
-  _process_reload('process_download_and_update', @_);
+  _process_reload(\&process_download_and_update, @_);
 }
 
 }
 
-  
+#
+#
+# END OF CCH STUFF
+#
+#
+
 =item process_batch_reload
 
 Load and process a tax update from the provided files as a queued JSRPC job
 =item process_batch_reload
 
 Load and process a tax update from the provided files as a queued JSRPC job
@@ -1499,15 +1721,12 @@ after wiping the existing wipable tax data.
 =cut
 
 sub process_batch_reload {
 =cut
 
 sub process_batch_reload {
-  _process_reload('_perform_batch_import', @_);
+  _process_reload(\&_perform_batch_import, @_);
 }
 
 }
 
-  
 sub _process_reload {
 sub _process_reload {
-  my ( $method, $job ) = ( shift, shift );
-
-  my $param = thaw(decode_base64($_[0]));
-  my $format = $param->{'format'};        #well... this is all cch specific
+  my ( $continuation, $job, $param ) = @_;
+  my $format = $param->{'format'};
 
   my ( $imported, $last, $min_sec ) = _progressbar_foo();
 
 
   my ( $imported, $last, $min_sec ) = _progressbar_foo();
 
@@ -1521,47 +1740,79 @@ sub _process_reload {
   my $dbh = dbh;
   my $error = '';
 
   my $dbh = dbh;
   my $error = '';
 
-  my $sql =
-    "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
-    "USING (taxclassnum) WHERE data_vendor = '$format'";
-  my $sth = $dbh->prepare($sql) or die $dbh->errstr;
-  $sth->execute
-    or die "Unexpected error executing statement $sql: ". $sth->errstr;
-  die "Don't (yet) know how to handle part_pkg_taxoverride records."
-    if $sth->fetchrow_arrayref->[0];
-
-  # really should get a table EXCLUSIVE lock here
-
-  #remember disabled taxes
-  my %disabled_tax_rate = ();
-  $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
-
-  #remember tax products
-  my %taxproduct = ();
-  $error ||= _remember_tax_products( $job, $format, \%taxproduct );
-
-  #create temp tables
-  $error ||= _create_temporary_tables( $job, $format );
-
-  #import new data
-  unless ($error) {
-    my $args = '$job, @_';
-    eval "$method($args);";
-    $error = $@ if $@;
-  }
+  if ( $format =~ /^cch/ ) {
+    # no, THIS part is CCH specific
 
 
-  #restore taxproducts
-  $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
+    my $sql =
+      "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
+      "USING (taxclassnum) WHERE data_vendor = '$format'";
+    my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+    $sth->execute
+      or die "Unexpected error executing statement $sql: ". $sth->errstr;
+    die "Don't (yet) know how to handle part_pkg_taxoverride records."
+      if $sth->fetchrow_arrayref->[0];
+
+    # really should get a table EXCLUSIVE lock here
 
 
-  #disable tax_rates
-  $error ||=
-   _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
+    #remember disabled taxes
+    my %disabled_tax_rate = ();
+    $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
 
 
-  #wipe out the old data
-  $error ||= _remove_old_tax_data( $job, $format ); 
+    #remember tax products
+    my %taxproduct = ();
+    $error ||= _remember_tax_products( $job, $format, \%taxproduct );
 
 
-  #untemporize
-  $error ||= _copy_from_temp( $job, $format );
+    #create temp tables
+    $error ||= _create_temporary_tables( $job, $format );
+
+    #import new data
+    unless ($error) {
+      eval { &{$continuation}( $job, $param ) };
+      $error = $@ if $@;
+    }
+
+    #restore taxproducts
+    $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
+
+    #disable tax_rates
+    $error ||=
+     _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
+
+    #wipe out the old data
+    $error ||= _remove_old_tax_data( $job, $format ); 
+
+    #untemporize
+    $error ||= _copy_from_temp( $job, $format );
+
+  } elsif ( $format =~ /^billsoft-(\w+)/ ) {
+
+    my $mode = $1;
+    my @sql;
+    if ( $mode eq 'pcode' ) {
+      push @sql,
+        "DELETE FROM cust_tax_location WHERE data_vendor = 'billsoft'",
+        "UPDATE tax_rate_location SET disabled = 'Y' WHERE data_vendor = 'billsoft'";
+    } elsif ( $mode eq 'taxclass' ) {
+      push @sql,
+        "DELETE FROM tax_class WHERE data_vendor = 'billsoft'";
+    } elsif ( $mode eq 'taxproduct' ) {
+      push @sql,
+        "DELETE FROM part_pkg_taxproduct WHERE data_vendor = 'billsoft'";
+    }
+
+    foreach (@sql) {
+      if (!$dbh->do($_)) {
+        $error = $dbh->errstr;
+        last;
+      }
+    }
+
+    unless ( $error ) {
+      local $@;
+      eval { &{ $continuation }($job, $param) };
+      $error = $@;
+    }
+  } # if ($format ...)
 
   if ($error) {
     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
 
   if ($error) {
     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
@@ -1582,7 +1833,7 @@ Download and process a tax update as a queued JSRPC job
 sub process_download_and_update {
   my $job = shift;
 
 sub process_download_and_update {
   my $job = shift;
 
-  my $param = thaw(decode_base64(shift));
+  my $param = shift;
   my $format = $param->{'format'};        #well... this is all cch specific
 
   my ( $imported, $last, $min_sec ) = _progressbar_foo();
   my $format = $param->{'format'};        #well... this is all cch specific
 
   my ( $imported, $last, $min_sec ) = _progressbar_foo();
@@ -1630,16 +1881,16 @@ sub process_download_and_update {
 
     if (-d $dir) {
 
 
     if (-d $dir) {
 
-      if (-d "$dir.4") {
-        opendir(my $dirh, "$dir.4") or die "failed to open $dir.4: $!\n";
+      if (-d "$dir.9") {
+        opendir(my $dirh, "$dir.9") or die "failed to open $dir.9: $!\n";
         foreach my $file (readdir($dirh)) {
         foreach my $file (readdir($dirh)) {
-          unlink "$dir.4/$file" if (-f "$dir.4/$file");
+          unlink "$dir.9/$file" if (-f "$dir.9/$file");
         }
         closedir($dirh);
         }
         closedir($dirh);
-        rmdir "$dir.4";
+        rmdir "$dir.9";
       }
 
       }
 
-      for (3, 2, 1) {
+      for (8, 7, 6, 5, 4, 3, 2, 1) {
         if ( -e "$dir.$_" ) {
           rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
         }
         if ( -e "$dir.$_" ) {
           rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
         }
@@ -1685,7 +1936,7 @@ sub process_download_and_update {
     $param->{uploaded_files} = join( ',', @list );
     $param->{format} .= '-update' if $update;
     $error ||=
     $param->{uploaded_files} = join( ',', @list );
     $param->{format} .= '-update' if $update;
     $error ||=
-      _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
+      _perform_batch_import( $job, $param );
     
     rename "$dir.new", "$dir"
       or die "cch tax update processed, but can't rename $dir.new: $!\n";
     
     rename "$dir.new", "$dir"
       or die "cch tax update processed, but can't rename $dir.new: $!\n";
@@ -1778,11 +2029,17 @@ sub browse_queries {
 =item queue_liability_report PARAMS
 
 Launches a tax liability report.
 =item queue_liability_report PARAMS
 
 Launches a tax liability report.
+
+PARAMS needs to be a base64-encoded Storable hash containing:
+- beginning: the start date, as a I<user-readable string> (not a timestamp).
+- end: the end date of the report, likewise.
+- agentnum: the agent to limit the report to, if any.
+
 =cut
 
 sub queue_liability_report {
   my $job = shift;
 =cut
 
 sub queue_liability_report {
   my $job = shift;
-  my $param = thaw(decode_base64(shift));
+  my $param = shift;
 
   my $cgi = new CGI;
   $cgi->param('beginning', $param->{beginning});
 
   my $cgi = new CGI;
   $cgi->param('beginning', $param->{beginning});
@@ -1801,8 +2058,12 @@ sub queue_liability_report {
 
 =item generate_liability_report PARAMS
 
 
 =item generate_liability_report PARAMS
 
-Generates a tax liability report.  Provide a hash including desired
-agentnum, beginning, and ending
+Generates a tax liability report.  PARAMS must include:
+
+- beginning, as a timestamp
+- ending, as a timestamp
+- p: the Freeside root URL, for generating links
+- agentnum (optional)
 
 =cut
 
 
 =cut
 
@@ -1843,12 +2104,9 @@ sub generate_liability_report {
     $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
   }
 
     $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
   }
 
-  # my ( $location_sql, @location_param ) = FS::cust_pkg->location_sql;
-  # $where .= " AND $location_sql";
-  #my @taxparam = ( 'itemdesc', @location_param );
-  # now something along the lines of geocode matching ?
-  #$where .= FS::cust_pkg->_location_sql_where('cust_tax_location');;
-  my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
+  #my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
+  my @taxparams = qw( city county state locationtaxid );
+  my @params = ('itemdesc', @taxparams);
 
   my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
 
 
   my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
 
@@ -1867,11 +2125,16 @@ sub generate_liability_report {
   my %taxes = ();
   my %basetaxes = ();
   my $calculated = 0;
   my %taxes = ();
   my %basetaxes = ();
   my $calculated = 0;
+
+  # get all distinct tuples of (tax name, state, county, city, locationtaxid)
+  # for taxes that have been charged
+  # (state, county, city are from tax_rate_location, not from customer data)
   my @tax_and_location = qsearch({ table     => 'cust_bill_pkg',
                                    select    => $select,
                                    hashref   => { pkgpart => 0 },
                                    addl_from => $addl_from,
                                    extra_sql => $where,
   my @tax_and_location = qsearch({ table     => 'cust_bill_pkg',
                                    select    => $select,
                                    hashref   => { pkgpart => 0 },
                                    addl_from => $addl_from,
                                    extra_sql => $where,
+                                   debug     => 1,
                                 });
   $count = scalar(@tax_and_location);
   foreach my $t ( @tax_and_location ) {
                                 });
   $count = scalar(@tax_and_location);
   foreach my $t ( @tax_and_location ) {
@@ -1879,13 +2142,13 @@ sub generate_liability_report {
     if ( $args{job} ) {
       if ( time - $min_sec > $last ) {
         $args{job}->update_statustext( int( 100 * $calculated / $count ).
     if ( $args{job} ) {
       if ( time - $min_sec > $last ) {
         $args{job}->update_statustext( int( 100 * $calculated / $count ).
-                                       ",Calculated"
+                                       ",Calculating"
                                      );
         $last = time;
       }
     }
 
                                      );
         $last = time;
       }
     }
 
-    my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
+    #my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
     my $label = join('~', map { $t->$_ } @params);
     $label = 'Tax'. $label if $label =~ /^~/;
     unless ( exists( $taxes{$label} ) ) {
     my $label = join('~', map { $t->$_ } @params);
     $label = 'Tax'. $label if $label =~ /^~/;
     unless ( exists( $taxes{$label} ) ) {
@@ -1895,24 +2158,30 @@ sub generate_liability_report {
       $taxes{$label}->{'url_param'} =
         join(';', map { "$_=". uri_escape($t->$_) } @params);
 
       $taxes{$label}->{'url_param'} =
         join(';', map { "$_=". uri_escape($t->$_) } @params);
 
-      my $taxwhere = "FROM cust_bill_pkg $addl_from $where AND payby != 'COMP' ".
-        "AND ". join( ' AND ', map { "( $_ = ? OR ? = '' AND $_ IS NULL)" } @taxparam );
+      my $itemdesc_loc = 
+        "    ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
+        "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
+                                                         @taxparams
+                                                   );
+
+      my $taxwhere =
+        "FROM cust_bill_pkg $addl_from $where AND $itemdesc_loc";
 
       my $sql = "SELECT SUM(amount) $taxwhere AND cust_bill_pkg.pkgnum = 0";
 
 
       my $sql = "SELECT SUM(amount) $taxwhere AND cust_bill_pkg.pkgnum = 0";
 
-      my $x = &{$scalar_sql}($t, [ map { $_, $_ } @params ], $sql );
+      my $x = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
       $tax += $x;
       $taxes{$label}->{'tax'} += $x;
 
       $tax += $x;
       $taxes{$label}->{'tax'} += $x;
 
-      my $creditfrom = " JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum) ";
-      my $creditwhere = "FROM cust_bill_pkg $addl_from $creditfrom $where ".
-        "AND payby != 'COMP' ".
-        "AND ". join( ' AND ', map { "( $_ = ? OR ? = '' AND $_ IS NULL)" } @taxparam );
+      my $creditfrom =
+       "JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum)";
+      my $creditwhere =
+        "FROM cust_bill_pkg $addl_from $creditfrom $where AND $itemdesc_loc";
 
       $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
              " $creditwhere AND cust_bill_pkg.pkgnum = 0";
 
 
       $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
              " $creditwhere AND cust_bill_pkg.pkgnum = 0";
 
-      my $y = &{$scalar_sql}($t, [ map { $_, $_ } @params ], $sql );
+      my $y = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
       $credit += $y;
       $taxes{$label}->{'credit'} += $y;
 
       $credit += $y;
       $taxes{$label}->{'credit'} += $y;
 
@@ -1971,8 +2240,9 @@ sub generate_liability_report {
   my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
   $dateagentlink .= ';agentnum='. $args{agentnum}
     if length($agentname);
   my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
   $dateagentlink .= ';agentnum='. $args{agentnum}
     if length($agentname);
-  my $baselink   = $args{p}. "search/cust_bill_pkg.cgi?$dateagentlink";
-
+  my $baselink   = $args{p}. "search/cust_bill_pkg.cgi?vendortax=1;" .
+                             $dateagentlink;
+  my $creditlink = $args{p}. "search/cust_credit_bill_pkg.html?$dateagentlink";
 
   print $report <<EOF;
   
 
   print $report <<EOF;
   
@@ -1994,7 +2264,7 @@ sub generate_liability_report {
     <TR>
       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
     <TR>
       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
-      <TH CLASS="grid" BGCOLOR="#cccccc">Tax collected</TH>
+      <TH CLASS="grid" BGCOLOR="#cccccc">Tax invoiced</TH>
       <TH CLASS="grid" BGCOLOR="#cccccc">&nbsp;&nbsp;&nbsp;&nbsp;</TH>
       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
       <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
       <TH CLASS="grid" BGCOLOR="#cccccc">&nbsp;&nbsp;&nbsp;&nbsp;</TH>
       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
       <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
@@ -2040,7 +2310,7 @@ EOF
         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
         <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
         <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
-          <A HREF="<% '$baselink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
+          <A HREF="<% '$creditlink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
         </TD>
         <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
       </TR>
         </TD>
         <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
       </TR>
@@ -2060,26 +2330,39 @@ EOF
   my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
   $reportname =~ s/^$dropstring//;
 
   my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
   $reportname =~ s/^$dropstring//;
 
-  my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname";
+  my $reporturl = "%%%ROOTURL%%%/misc/queued_report.html?report=$reportname";
   die "<a href=$reporturl>view</a>\n";
 
 }
 
   die "<a href=$reporturl>view</a>\n";
 
 }
 
+sub _upgrade_data {
+  my $class = shift;
 
 
+  my $sql = "UPDATE tax_rate SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'";
+
+  my $sth = dbh->prepare($sql) or die $DBI::errstr;
+  $sth->execute() or die $sth->errstr;
+  
+}
 
 =back
 
 =head1 BUGS
 
 
 =back
 
 =head1 BUGS
 
+  Highly specific to CCH taxes.  This should arguably go in some kind of 
+  subclass (FS::tax_rate::CCH) with auto-reblessing, similar to part_pkg
+  subclasses.  But currently there aren't any other options, so.
+
   Mixing automatic and manual editing works poorly at present.
 
   Tax liability calculations take too long and arguably don't belong here.
   Tax liability report generation not entirely safe (escaped).
 
   Mixing automatic and manual editing works poorly at present.
 
   Tax liability calculations take too long and arguably don't belong here.
   Tax liability report generation not entirely safe (escaped).
 
+  Sparse documentation.
+
 =head1 SEE ALSO
 
 =head1 SEE ALSO
 
-L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
-documentation.
+L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>
 
 =cut
 
 
 =cut