multiple payment options (new complimentary flag), RT#23741
[freeside.git] / FS / FS / tax_rate.pm
index 9e458e2..0047f9d 100644 (file)
@@ -8,7 +8,6 @@ use vars qw( $DEBUG $me
 use Date::Parse;
 use DateTime;
 use DateTime::Format::Strptime;
 use Date::Parse;
 use DateTime;
 use DateTime::Format::Strptime;
-use Storable qw( thaw nfreeze );
 use IO::File;
 use File::Temp;
 use Text::CSV_XS;
 use IO::File;
 use File::Temp;
 use Text::CSV_XS;
@@ -16,7 +15,6 @@ 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;
@@ -80,9 +78,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,7 +213,7 @@ sub check {
     || $self->ut_text('geocode')
     || $self->ut_textn('data_vendor')
     || $self->ut_cch_textn('location')
     || $self->ut_text('geocode')
     || $self->ut_textn('data_vendor')
     || $self->ut_cch_textn('location')
-    || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
+    || $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')
@@ -286,16 +285,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 {
@@ -371,7 +379,7 @@ sub passtype_name {
   $tax_passtypes{$self->passtype};
 }
 
   $tax_passtypes{$self->passtype};
 }
 
-=item taxline TAXABLES
+=item taxline_cch TAXABLES, [ OPTIONSHASH ]
 
 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
 
 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
@@ -379,7 +387,7 @@ is returned as a scalar.
 
 =cut
 
 
 =cut
 
-sub taxline {
+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 $self = shift;
   # this used to accept a hash of options but none of them did anything
   # so it's been removed.
@@ -423,17 +431,14 @@ sub taxline {
   }
 
   my $maxtype = $self->maxtype || 0;
   }
 
   my $maxtype = $self->maxtype || 0;
-  if ($maxtype != 0 && $maxtype != 1 && $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)
@@ -493,6 +498,15 @@ sub taxline {
   # the tax or fee is applied to taxbase or feebase and then
   # the excessrate or excess fee is applied to taxmax or feemax
 
   # 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
+       ($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 );
+  }
+
   $amount += $taxable_charged * $self->tax;
   $amount += $taxable_units * $self->fee;
   
   $amount += $taxable_charged * $self->tax;
   $amount += $taxable_units * $self->fee;
   
@@ -509,6 +523,8 @@ sub taxline {
 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;
@@ -597,6 +613,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
@@ -880,20 +926,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++;
@@ -914,35 +962,25 @@ Load a batch import as a queued JSRPC job
 =cut
 
 sub process_batch_import {
 =cut
 
 sub process_batch_import {
-  my $job = shift;
-
-  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 ($job, $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.";
@@ -950,20 +988,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,
@@ -1032,19 +1068,45 @@ 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 {
   my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
 
 sub _perform_cch_tax_import {
   my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
@@ -1304,11 +1366,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 => {},
@@ -1530,15 +1595,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
@@ -1547,15 +1617,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();
 
@@ -1569,47 +1636,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
+
+    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 );
 
 
-  #restore taxproducts
-  $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
+    #remember tax products
+    my %taxproduct = ();
+    $error ||= _remember_tax_products( $job, $format, \%taxproduct );
 
 
-  #disable tax_rates
-  $error ||=
-   _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
+    #create temp tables
+    $error ||= _create_temporary_tables( $job, $format );
 
 
-  #wipe out the old data
-  $error ||= _remove_old_tax_data( $job, $format ); 
+    #import new data
+    unless ($error) {
+      eval { &{$continuation}( $job, $param ) };
+      $error = $@ if $@;
+    }
+
+    #restore taxproducts
+    $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
 
 
-  #untemporize
-  $error ||= _copy_from_temp( $job, $format );
+    #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;
@@ -1630,7 +1729,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();
@@ -1733,7 +1832,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";
@@ -1836,7 +1935,7 @@ PARAMS needs to be a base64-encoded Storable hash containing:
 
 sub queue_liability_report {
   my $job = shift;
 
 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});
@@ -1956,9 +2055,6 @@ sub generate_liability_report {
         join(';', map { "$_=". uri_escape($t->$_) } @params);
 
       my $itemdesc_loc = 
         join(';', map { "$_=". uri_escape($t->$_) } @params);
 
       my $itemdesc_loc = 
-      # "    payby != 'COMP' ". # breaks the entire report under 4.x
-      #                         # and unnecessary since COMP accounts don't
-      #                         # get taxes calculated in the first place
         "    ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
         "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
                                                          @taxparams
         "    ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
         "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
                                                          @taxparams
@@ -2141,11 +2237,17 @@ EOF
 
 =head1 BUGS
 
 
 =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
 
 L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>