[freeside-commits] branch master updated. 817c1ce0e1cbcfd1f684222c66f46dd13b2d6dd7

Mark Wells mark at 420.am
Sat May 30 15:27:22 PDT 2015


The branch, master has been updated
       via  817c1ce0e1cbcfd1f684222c66f46dd13b2d6dd7 (commit)
      from  3846acae1c2a7ecb275e400cf3802ada6bc89ed2 (commit)

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

- Log -----------------------------------------------------------------
commit 817c1ce0e1cbcfd1f684222c66f46dd13b2d6dd7
Author: Mark Wells <mark at freeside.biz>
Date:   Sat May 30 15:12:07 2015 -0700

    SureTax, #31639, #33015, #34598

diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index f80f2d5..17a7c23 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -2451,55 +2451,84 @@ and customer address. Include units.',
 
   {
     'key'         => 'enable_taxclasses',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Enable per-package tax classes',
     'type'        => 'checkbox',
   },
 
   {
     'key'         => 'require_taxclasses',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Require a taxclass to be entered for every package',
     'type'        => 'checkbox',
   },
 
   {
-    'key'         => 'enable_taxproducts',
-    'section'     => 'billing',
+    'key'         => 'tax_data_vendor',
+    'section'     => 'taxation',
     'description' => 'Tax data vendor you are using.',
     'type'        => 'select',
-    'select_enum' => [ 'cch', 'billsoft', 'avalara' ],
+    'select_enum' => [ '', 'cch', 'billsoft', 'avalara', 'suretax' ],
   },
 
   {
     'key'         => 'taxdatadirectdownload',
-    'section'     => 'billing',  #well
-    'description' => 'Enable downloading tax data directly from the vendor site. at least three lines: URL, username, and password.j',
+    'section'     => 'taxation',
+    'description' => 'Enable downloading tax data directly from CCH. at least three lines: URL, username, and password.j',
     'type'        => 'textarea',
   },
 
   {
     'key'         => 'ignore_incalculable_taxes',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Prefer to invoice without tax over not billing at all',
     'type'        => 'checkbox',
   },
 
   {
     'key'         => 'billsoft-company_code',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Billsoft tax service company code (3 letters)',
     'type'        => 'text',
   },
 
   {
     'key'         => 'avalara-taxconfig',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Avalara tax service configuration. Four lines: company code, account number, license key, test mode (1 to enable).',
     'type'        => 'textarea',
   },
 
   {
+    'key'         => 'suretax-client_number',
+    'section'     => 'taxation',
+    'description' => 'SureTax tax service client ID.',
+    'type'        => 'text',
+  },
+  {
+    'key'         => 'suretax-validation_key',
+    'section'     => 'taxation',
+    'description' => 'SureTax validation key (UUID).',
+    'type'        => 'text',
+  },
+  {
+    'key'         => 'suretax-business_unit',
+    'section'     => 'taxation',
+    'description' => 'SureTax client business unit name; optional.',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+  {
+    'key'         => 'suretax-regulatory_code',
+    'section'     => 'taxation',
+    'description' => 'SureTax client regulatory status.',
+    'type'        => 'select',
+    'select_enum' => [ '', 'ILEC', 'IXC', 'CLEC', 'VOIP', 'ISP', 'Wireless' ],
+    'per_agent'   => 1,
+  },
+
+
+  {
     'key'         => 'welcome_msgnum',
     'section'     => 'notification',
     'description' => 'Template to use for welcome messages when a svc_acct record is created.',
@@ -3678,14 +3707,14 @@ and customer address. Include units.',
 
   {
     'key'         => 'tax-ship_address',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'By default, tax calculations are done based on the billing address.  Enable this switch to calculate tax based on the shipping address instead.',
     'type'        => 'checkbox',
   }
 ,
   {
     'key'         => 'tax-pkg_address',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'By default, tax calculations are done based on the billing address.  Enable this switch to calculate tax based on the package address instead (when present).',
     'type'        => 'checkbox',
   },
@@ -4467,7 +4496,7 @@ and customer address. Include units.',
 
   {
     'key'         => 'tax_district_method',
-    'section'     => 'UI',
+    'section'     => 'taxation',
     'description' => 'The method to use to look up tax district codes.',
     'type'        => 'select',
     #'select_hash' => [ FS::Misc::Geo::get_district_methods() ],
@@ -5228,7 +5257,7 @@ and customer address. Include units.',
 
   {
     'key'         => 'tax-cust_exempt-groups',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'List of grouping possibilities for tax names, for per-customer exemption purposes, one tax name per line.  For example, "GST" would indicate the ability to exempt customers individually from taxes named "GST" (but not other taxes).',
     'type'        => 'textarea',
   },
@@ -5242,7 +5271,7 @@ and customer address. Include units.',
 
   {
     'key'         => 'tax-cust_exempt-groups-num_req',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'When using tax-cust_exempt-groups, control whether individual tax exemption numbers are required for exemption from different taxes.',
     'type'        => 'select',
     'select_hash' => [ ''            => 'Not required',
@@ -5270,7 +5299,7 @@ and customer address. Include units.',
 
   {
     'key'         => 'enable_tax_adjustments',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Enable the ability to add manual tax adjustments.',
     'type'        => 'checkbox',
   },
@@ -5723,7 +5752,7 @@ and customer address. Include units.',
 
   {
     'key'         => 'cust_class-tax_exempt',
-    'section'     => 'billing',
+    'section'     => 'taxation',
     'description' => 'Control the tax exemption flag per customer class rather than per indivual customer.',
     'type'        => 'checkbox',
   },
diff --git a/FS/FS/Cursor.pm b/FS/FS/Cursor.pm
index 67a98ea..faa15f9 100644
--- a/FS/FS/Cursor.pm
+++ b/FS/FS/Cursor.pm
@@ -4,7 +4,7 @@ use strict;
 use vars qw($DEBUG $buffer);
 use FS::Record;
 use FS::UID qw(myconnect driver_name);
-use Scalar::Util qw(refaddr);
+use Scalar::Util qw(refaddr blessed);
 
 $DEBUG = 2;
 
@@ -29,17 +29,24 @@ while ( my $row = $search->fetch ) {
 
 =over 4
 
-=item new ARGUMENTS
+=item new ARGUMENTS [, DBH ]
 
 Constructs a cursored search.  Accepts all the same arguments as qsearch,
 and returns an FS::Cursor object to fetch the rows one at a time.
 
+DBH may be a database handle; if so, the cursor will be created on that 
+connection and have all of its transaction state. Otherwise a new connection
+will be opened for the cursor.
+
 =cut
 
 sub new {
   my $class = shift;
-  my $q = FS::Record::_query(@_); # builds the statement and parameter list
   my $dbh;
+  if ( blessed($_[-1]) and $_[-1]->isa('DBI::db') ) {
+    $dbh = pop;
+  }
+  my $q = FS::Record::_query(@_); # builds the statement and parameter list
 
   my $self = {
     query => $q,
@@ -59,7 +66,11 @@ sub new {
 
   my $statement;
   if ( driver_name() eq 'Pg' ) {
-    $self->{dbh} = $dbh = myconnect();
+    if (!$dbh) {
+      $dbh = myconnect();
+      $self->{autoclean} = 1;
+    }
+    $self->{dbh} = $dbh;
     $statement = "DECLARE ".$self->{id}." CURSOR FOR ".$q->{statement};
   } elsif ( driver_name() eq 'mysql' ) {
     # build a cursor from scratch
@@ -144,8 +155,11 @@ sub DESTROY {
   return unless $self->{pid} eq $$;
   $self->{dbh}->do('CLOSE '. $self->{id})
     or die $self->{dbh}->errstr; # clean-up the cursor in Pg
-  $self->{dbh}->rollback;
-  $self->{dbh}->disconnect;
+  if ($self->{autoclean}) {
+    # the dbh was created just for this cursor, so it has no transaction 
+    # state that we care about 
+    $self->{dbh}->rollback;
+  }
 }
 
 =back
@@ -159,12 +173,6 @@ Replace all uses of qsearch with this.
 Still doesn't really support MySQL, but it pretends it does, by simply
 running the query and returning records one at a time.
 
-The cursor will close prematurely if any code issues a rollback/commit. If
-you need protection against this use qsearch or fork and get a new dbh
-handle.
-Normally this issue will represent itself this message.
-ERROR: cursor "cursorXXXXXXX" does not exist.
-
 =head1 SEE ALSO
 
 L<FS::Record>
diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm
index ac30eb1..4560142 100644
--- a/FS/FS/TaxEngine.pm
+++ b/FS/FS/TaxEngine.pm
@@ -35,10 +35,28 @@ FS::TaxEngine - Base class for tax calculation engines.
 
 =over 4
 
+=item class
+
+Returns the class name for tax engines, according to the 'tax_data_vendor'
+configuration setting.
+
+=cut
+
+sub class {
+  my $conf = FS::Conf->new;
+  my $subclass = $conf->config('tax_data_vendor') || 'internal';
+  my $class = "FS::TaxEngine::$subclass";
+  local $@;
+  eval "use $class";
+  die "couldn't load $class: $@\n" if $@;
+
+  $class;
+}
+
 =item new 'cust_main' => CUST_MAIN, 'invoice_time' => TIME, OPTIONS...
 
 Creates an L<FS::TaxEngine> object.  The subclass will be chosen by the 
-'enable_taxproducts' configuration setting.
+'tax_data_vendor' configuration setting.
 
 CUST_MAIN and TIME are required.  OPTIONS can include:
 
@@ -54,11 +72,7 @@ sub new {
   my %opt = @_;
   my $conf = FS::Conf->new;
   if ($class eq 'FS::TaxEngine') {
-    my $subclass = $conf->config('enable_taxproducts') || 'internal';
-    $class .= "::$subclass";
-    local $@;
-    eval "use $class";
-    die "couldn't load $class: $@\n" if $@;
+    $class = $class->class;
   }
   my $self = { items => [], taxes => {}, conf => $conf, %opt };
   bless $self, $class;
@@ -107,6 +121,11 @@ sub calculate_taxes {
   my $cust_bill = shift;
 
   my @raw_taxlines = $self->make_taxlines($cust_bill);
+  if ( !@raw_taxlines ) {
+    return;
+  } elsif ( !ref $raw_taxlines[0] ) { # error message
+    return $raw_taxlines[0];
+  }
 
   my @real_taxlines = $self->consolidate_taxlines(@raw_taxlines);
 
@@ -117,12 +136,13 @@ sub calculate_taxes {
 }
 
 sub make_taxlines {
+  # only used by FS::TaxEngine::internal; should just move there
   my $self = shift;
   my $conf = $self->{conf};
 
   my $cust_bill = shift;
 
-  my @taxlines;
+  my @raw_taxlines;
 
   # For each distinct tax rate definition, calculate the tax and exemptions.
   foreach my $taxnum ( keys %{ $self->{taxes} } ) {
@@ -134,16 +154,17 @@ sub make_taxlines {
     # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects
     # (setup, recurring, usage classes)
 
-    my $taxline = $self->taxline('tax' => $tax_object, 'sales' => $taxables);
-    # taxline methods are now required to return real line items
-    # with their link records
-    die $taxline unless ref($taxline);
+    my @taxlines = $self->taxline('tax' => $tax_object, 'sales' => $taxables);
+    # taxline methods are now required to return the link records alone.
+    # Consolidation will take care of the rest.
+    next if !@taxlines;
+    die $taxlines[0] unless ref($taxlines[0]);
 
-    push @taxlines, $taxline;
+    push @raw_taxlines, @taxlines;
 
   } #foreach $taxnum
 
-  return @taxlines;
+  return @raw_taxlines;
 }
 
 sub consolidate_taxlines {
@@ -152,14 +173,16 @@ sub consolidate_taxlines {
   my $conf = $self->{conf};
 
   my @raw_taxlines = @_;
+  return if !@raw_taxlines; # shouldn't even be here
+
   my @tax_line_items;
 
   # keys are tax names (as printed on invoices / itemdesc )
-  # values are arrayrefs of taxlines
+  # values are arrayrefs of tax links ("raw taxlines")
   my %taxname;
   # collate these by itemdesc
   foreach my $taxline (@raw_taxlines) {
-    my $taxname = $taxline->itemdesc;
+    my $taxname = $taxline->taxname;
     $taxname{$taxname} ||= [];
     push @{ $taxname{$taxname} }, $taxline;
   }
@@ -168,7 +191,7 @@ sub consolidate_taxlines {
   # values are (cumulative) amounts
   my %tax_amount;
 
-  my $link_table = $self->info->{link_table};
+  my $link_table = $raw_taxlines[0]->table;
 
   # Preconstruct cust_bill_pkg objects that will become the "final"
   # taxlines for each name, so that we can reference them.
@@ -187,32 +210,30 @@ sub consolidate_taxlines {
   # create a consolidated tax item with the total amount and all the links
   # of all tax items that share that name.
   foreach my $taxname ( keys %taxname ) {
-    my @tax_links;
+    my $tax_links = $taxname{$taxname};
     my $tax_cust_bill_pkg = $real_taxline_named{$taxname};
-    $tax_cust_bill_pkg->set( $link_table => \@tax_links );
+    $tax_cust_bill_pkg->set( $link_table => $tax_links );
 
     my $tax_total = 0;
     warn "adding $taxname\n" if $DEBUG > 1;
 
-    foreach my $taxitem ( @{ $taxname{$taxname} } ) {
+    foreach my $link ( @$tax_links ) {
       # then we need to transfer the amount and the links from the
       # line item to the new one we're creating.
-      $tax_total += $taxitem->setup;
-      foreach my $link ( @{ $taxitem->get($link_table) } ) {
-        $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
-
-        # if the link represents tax on tax, also fix its taxable pointer
-        # to point to the "final" taxline
-        my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg');
-        if (my $other_taxname = $taxable_cust_bill_pkg->itemdesc) {
-          $link->set('taxable_cust_bill_pkg',
-            $real_taxline_named{$other_taxname}
-          );
-        }
-
-        push @tax_links, $link;
+      $tax_total += $link->amount;
+      $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
+
+      # if the link represents tax on tax, also fix its taxable pointer
+      # to point to the "final" taxline
+      my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg');
+      if ( $taxable_cust_bill_pkg and
+           my $other_taxname = $taxable_cust_bill_pkg->itemdesc) {
+        $link->set('taxable_cust_bill_pkg',
+          $real_taxline_named{$other_taxname}
+        );
       }
-    } # foreach $taxitem
+
+    } # foreach $link
     next unless $tax_total;
 
     # we should really neverround this up...I guess it's okay if taxline 
diff --git a/FS/FS/TaxEngine/cch.pm b/FS/FS/TaxEngine/cch.pm
index 4e6dbaf..fb34103 100644
--- a/FS/FS/TaxEngine/cch.pm
+++ b/FS/FS/TaxEngine/cch.pm
@@ -5,6 +5,7 @@ use vars qw( $DEBUG );
 use base 'FS::TaxEngine';
 use FS::Record qw(dbh qsearch qsearchs);
 use FS::Conf;
+use List::Util qw(sum);
 
 =head1 SUMMARY
 
@@ -131,32 +132,21 @@ sub make_taxlines {
       $taxable_location{ $_->billpkgnum } ||= $_->tax_location;
     }
 
-    my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes );
-
-    next if !@taxlines;
-    if (!ref $taxlines[0]) {
+    foreach my $link ( $tax_rate->taxline_cch( $taxables, $charge_classes ) ) {
+      if (!ref $link) {
       # 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;
+        die "error evaluating tax#$taxnum: $link\n";
+      }
+      next if $link->amount == 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} ||= [];
-      }
+      my $billpkgnum = $link->taxable_billpkgnum;
+      $item_has_tax{$billpkgnum} ||= {};
+      my $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
+      push @raw_taxlines, $link; # this will go into final consolidation
+      push @$fragments, $link; # this will go into a temporary cust_bill_pkg
+                               # for ToT calculation
     }
   } # foreach $taxnum
 
@@ -167,6 +157,9 @@ sub make_taxlines {
     my $this_has_tax = $item_has_tax{$billpkgnum};
     my $location = $taxable_location{$billpkgnum};
     foreach my $taxnum (keys %$this_has_tax) {
+      # $this_has_tax->{$taxnum} = an arrayref of the tax links for taxdef 
+      # $taxnum on taxable item $billpkgnum
+
       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 );
@@ -177,6 +170,7 @@ sub make_taxlines {
       # Calculate ToT separately for each taxable item, and only if _that 
       # item_ is already taxed under the ToT.  This is counterintuitive.
       # See RT#5243.
+      my $temp_lineitem;
       foreach my $tot (@tot) { 
         my $totnum = $tot->taxnum;
         warn "checking taxnum ".$tot->taxnum. 
@@ -185,16 +179,22 @@ sub make_taxlines {
         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];
+          # construct a line item to calculate tax on
+          $temp_lineitem ||= FS::cust_bill_pkg->new({
+              'pkgnum'    => 0,
+              'invnum'    => $cust_bill->invnum,
+              'setup'     => sum(map $_->amount, @{ $this_has_tax->{$taxnum} }),
+              'recur'     => 0,
+              'itemdesc'  => $tax_rate->taxname,
+              'cust_bill_pkg_tax_rate_location' => $this_has_tax->{$taxnum},
+          });
+          my @new_taxlines = $tot->taxline_cch( [ $temp_lineitem ] );
+          next if (!@new_taxlines); # it didn't apply after all
+          if (!ref($new_taxlines[0])) {
+            die "error evaluating TOT ($totnum on $taxnum): $new_taxlines[0]\n";
           }
           # add these to the taxline queue
-          push @raw_taxlines, @taxlines;
+          push @raw_taxlines, @new_taxlines;
         } # if $this_has_tax->{$totnum}
       } # foreach my $tot (tax-on-tax rate definition)
     } # foreach $taxnum (first-tier rate definition)
diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm
index 99535ad..f45bc08 100644
--- a/FS/FS/TaxEngine/internal.pm
+++ b/FS/FS/TaxEngine/internal.pm
@@ -60,7 +60,6 @@ sub taxline {
   my $taxnum = $tax_object->taxnum;
   my $exemptions = $self->{exemptions}->{$taxnum} ||= [];
   
-  my $name = $tax_object->taxname || 'Tax';
   my $taxable_cents = 0;
   my $tax_cents = 0;
 
@@ -87,14 +86,7 @@ sub taxline {
   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
     foreach @$taxables;
 
-  my $tax_item = FS::cust_bill_pkg->new({
-      'pkgnum'    => 0,
-      'recur'     => 0,
-      'sdate'     => '',
-      'edate'     => '',
-      'itemdesc'  => $name,
-  });
-  my @tax_location;
+  my @tax_links;
 
   foreach my $cust_bill_pkg (@$taxables) {
 
@@ -274,9 +266,8 @@ sub taxline {
         'pkgnum'      => $cust_bill_pkg->pkgnum,
         'locationnum' => $cust_bill_pkg->cust_pkg->tax_locationnum,
         'taxable_cust_bill_pkg' => $cust_bill_pkg,
-        'tax_cust_bill_pkg'     => $tax_item,
     });
-    push @tax_location, $location;
+    push @tax_links, $location;
 
     $taxable_cents += $taxable_charged;
     $tax_cents += $this_tax_cents;
@@ -292,7 +283,7 @@ sub taxline {
   }
   $tax_cents += $extra_cents;
   my $i = 0;
-  foreach (@tax_location) { # can never require more than a single pass, yes?
+  foreach (@tax_links) { # can never require more than a single pass, yes?
     my $cents = $_->get('cents');
     if ( $extra_cents > 0 ) {
       $cents++;
@@ -300,10 +291,8 @@ sub taxline {
     }
     $_->set('amount', sprintf('%.2f', $cents/100));
   }
-  $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100));
-  $tax_item->set('cust_bill_pkg_tax_location', \@tax_location);
 
-  return $tax_item;
+  return @tax_links;
 }
 
 sub info {
diff --git a/FS/FS/TaxEngine/suretax.pm b/FS/FS/TaxEngine/suretax.pm
new file mode 100644
index 0000000..327a728
--- /dev/null
+++ b/FS/FS/TaxEngine/suretax.pm
@@ -0,0 +1,421 @@
+package FS::TaxEngine::suretax;
+
+use strict;
+use base 'FS::TaxEngine';
+use FS::Conf;
+use FS::Record qw(qsearch qsearchs dbh);
+use JSON;
+use XML::Simple qw(XMLin);
+use LWP::UserAgent;
+use HTTP::Request::Common;
+use DateTime;
+
+our $DEBUG = 1; # prints progress messages
+#   $DEBUG = 2; # prints decoded request and response (noisy, be careful)
+#   $DEBUG = 3; # prints raw response from the API, ridiculously unreadable
+
+our $json = JSON->new->pretty(1);
+
+our %taxproduct_cache;
+
+our $conf;
+
+our $host = 'testapi.taxrating.net';
+# production: 'api.taxrating.net'
+
+FS::UID->install_callback( sub {
+    $conf = FS::Conf->new;
+    # should we enable conf caching here?
+});
+
+# Tax Situs Rules, for determining tax jurisdiction.
+# (may need to be configurable)
+
+# For PSTN calls, use Rule 01, two-out-of-three using NPA-NXX. (The "three" 
+# are source number, destination number, and charged party number.)
+our $TSR_CALL_NPANXX = '01';
+
+# For other types of calls (on-network hosted PBX, SIP-addressed calls, 
+# other things that don't have an NPA-NXX number), use Rule 11. (See below.)
+our $TSR_CALL_OTHER = '11';
+
+# For regular recurring or one-time charges, use Rule 11. This uses the 
+# service zip code for transaction types that are known to require it, and
+# the billing zip code for all other transaction types.
+our $TSR_GENERAL = '11';
+
+# XXX incomplete; doesn't handle international taxes (Rule 14) or point
+# to point private lines (Rule 07).
+
+our %REGCODE = ( # can be selected per agent
+  ''          => '99',
+  'ILEC'      => '00',
+  'IXC'       => '01',
+  'CLEC'      => '02',
+  'VOIP'      => '03',
+  'ISP'       => '04',
+  'Wireless'  => '05',
+);
+
+sub info {
+  { batch => 0,
+    override => 0,
+  }
+}
+
+sub add_sale { } # nothing to do here
+
+sub build_request {
+  my ($self, %opt) = @_;
+
+  my $cust_bill = $self->{cust_bill};
+  my $cust_main = $cust_bill->cust_main;
+  my $agentnum = $cust_main->agentnum;
+  my $date = DateTime->from_epoch(epoch => $cust_bill->_date);
+
+  # remember some things that are linked to the customer
+  $self->{taxstatus} = $cust_main->taxstatus
+    or die "Customer #".$cust_main->custnum." has no tax status defined.\n";
+
+  ($self->{bill_zip}, $self->{bill_plus4}) =
+    split('-', $cust_main->bill_location->zip);
+
+  $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code') };
+
+  %taxproduct_cache = ();
+
+  # assemble invoice line items 
+  my @lines = map { $self->build_item($_) }
+              $cust_bill->cust_bill_pkg;
+
+  my $ClientNumber = $conf->config('suretax-client_number')
+    or die "suretax-client_number config required.\n";
+  my $ValidationKey = $conf->config('suretax-validation_key')
+    or die "suretax-validation_key config required.\n";
+  my $BusinessUnit = $conf->config('suretax-business_unit', $agentnum) || '';
+
+  return {
+    ClientNumber  => $ClientNumber,
+    ValidationKey => $ValidationKey,
+    BusinessUnit  => $BusinessUnit,
+    DataYear      => '2015', #$date->year,
+    DataMonth     => '04', #sprintf('%02d', $date->month),
+    TotalRevenue  => sprintf('%.4f', $cust_bill->charged),
+    ReturnFileCode    => ($self->{estimate} ? 'Q' : '0'),
+    ClientTracking  => $cust_bill->invnum,
+    IndustryExemption => '',
+    ResponseGroup => '13',
+    ResponseType  => 'D2',
+    STAN          => '',
+    ItemList      => \@lines,
+  };
+}
+
+=item build_item CUST_BILL_PKG
+
+Takes a sale item and returns any number of request element hashrefs
+corresponding to it. Yes, any number, because in a rated usage line item
+we have to send each usage detail separately.
+
+=cut
+
+sub build_item {
+  my $self = shift;
+  my $cust_bill_pkg = shift;
+  my $cust_bill = $cust_bill_pkg->cust_bill;
+  my $billpkgnum = $cust_bill_pkg->billpkgnum;
+  my $invnum = $cust_bill->invnum;
+  my $custnum = $cust_bill->custnum;
+
+  # get the part_pkg/fee for this line item, and the relevant part of the
+  # taxproduct cache
+  my $part_item = $cust_bill_pkg->part_X;
+  my $taxproduct_of_class = do {
+    my $part_id = $part_item->table . '#' . $part_item->get($part_item->primary_key);
+    $taxproduct_cache{$part_id} ||= {};
+  };
+
+  my @items;
+  my $recur_without_usage = $cust_bill_pkg->recur;
+
+  my $location = $cust_bill_pkg->tax_location;
+  my ($svc_zip, $svc_plus4) = split('-', $location->zip);
+
+  my $startdate =
+    DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
+
+  my %base_item = (
+    'LineNumber'      => '',
+    'InvoiceNumber'   => $billpkgnum,
+    'CustomerNumber'  => $custnum,
+    'OrigNumber'      => '',
+    'TermNumber'      => '',
+    'BillToNumber'    => '',
+    'Zipcode'         => $self->{bill_zip},
+    'Plus4'           => ($self->{bill_plus4} ||= '0000'),
+    'P2PZipcode'      => $svc_zip,
+    'P2PPlus4'        => ($svc_plus4 ||= '0000'),
+    # we don't support Order Placement/Approval zip codes
+    'Geocode'         => '',
+    'TransDate'       => $startdate,
+    'Revenue'         => '',
+    'Units'           => 0,
+    'UnitType'        => '00', # "number of unique lines", the only choice
+    'Seconds'         => 0,
+    'TaxIncludedCode' => '0',
+    'TaxSitusRule'    => '',
+    'TransTypeCode'   => '',
+    'SalesTypeCode'   => $self->{taxstatus},
+    'RegulatoryCode'  => $self->{regcode},
+    'TaxExemptionCodeList' => [ ],
+    'AuxRevenue'      => 0, # we don't currently support freight and such
+    'AuxRevenueType'  => '',
+  );
+
+  # some naming conventions:
+  # 'C#####' is a call detail record (using the acctid)
+  # 'S#####' is a cust_bill_pkg setup element (using the billpkgnum)
+  # 'R#####' is a cust_bill_pkg recur element
+  # always set "InvoiceNumber" = the billpkgnum, so we can link it properly
+
+  # cursor all this stuff; data sets can be LARGE
+  # (if it gets really out of hand, we can also incrementally write JSON
+  # to a file)
+
+  my $details = FS::Cursor->new('cust_bill_pkg_detail', {
+      billpkgnum  => $cust_bill_pkg->billpkgnum,
+      amount      => { op => '>', value => 0 }
+  }, dbh() );
+  while ( my $cust_bill_pkg_detail = $details->fetch ) {
+
+    # look up the tax product for this class
+    my $classnum = $cust_bill_pkg_detail->classnum;
+    my $taxproduct = $taxproduct_of_class->{ $classnum } ||= do {
+      my $part_pkg_taxproduct = $part_item->taxproduct($classnum);
+      $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
+    };
+    die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
+        ", usage class $classnum\n"
+        if !$taxproduct;
+
+    my $cdrs = FS::Cursor->new('cdr', {
+        detailnum       => $cust_bill_pkg_detail->detailnum,
+        freesidestatus  => 'done',
+    }, dbh() );
+    while ( my $cdr = $cdrs->fetch ) {
+      my $calldate =
+        DateTime->from_epoch( epoch => $cdr->startdate )->strftime('%m-%d-%Y');
+      # determine the tax situs rule; it's different (probably more accurate) 
+      # if the call has PSTN phone numbers at both ends
+      my $tsr = $TSR_CALL_OTHER;
+      if ( $cdr->charged_party =~ /^\d{10}$/ and
+           $cdr->src           =~ /^\d{10}$/ and
+           $cdr->dst           =~ /^\d{10}$/ ) {
+        $tsr = $TSR_CALL_NPANXX;
+      }
+      my %hash = (
+        %base_item,
+        'LineNumber'      => 'C' . $cdr->acctid,
+        'OrigNumber'      => $cdr->src,
+        'TermNumber'      => $cdr->dst,
+        'BillToNumber'    => $cdr->charged_party,
+        'TransDate'       => $calldate,
+        'Revenue'         => $cdr->rated_price, # 4 decimal places
+        'Units'           => 0, # right?
+        'CallDuration'    => $cdr->duration,
+        'TaxSitusRule'    => $tsr,
+        'TransTypeCode'   => $taxproduct,
+      );
+      push @items, \%hash;
+
+    } # while ($cdrs->fetch)
+
+    # decrement the recurring charge
+    $recur_without_usage -= $cust_bill_pkg_detail->amount;
+
+  } # while ($details->fetch)
+
+  # recurring charge
+  if ( $recur_without_usage > 0 ) {
+    my $taxproduct = $taxproduct_of_class->{ 'recur' } ||= do {
+      my $part_pkg_taxproduct = $part_item->taxproduct('recur');
+      $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
+    };
+    die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
+        " recurring charge\n"
+        if !$taxproduct;
+
+    my $tsr = $TSR_GENERAL;
+    my %hash = (
+      %base_item,
+      'LineNumber'      => 'R' . $billpkgnum,
+      'Revenue'         => $recur_without_usage, # 4 decimal places
+      'Units'           => $cust_bill_pkg->units,
+      'TaxSitusRule'    => $tsr,
+      'TransTypeCode'   => $taxproduct,
+    );
+    # API expects all these fields to be _present_, even when they're not 
+    # required
+    $hash{$_} = '' foreach(qw(OrigNumber TermNumber BillToNumber));
+    push @items, \%hash;
+  }
+
+  if ( $cust_bill_pkg->setup > 0 ) {
+    my $startdate =
+      DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
+    my $taxproduct = $taxproduct_of_class->{ 'setup' } ||= do {
+      my $part_pkg_taxproduct = $part_item->taxproduct('setup');
+      $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
+    };
+    die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
+        " setup charge\n"
+        if !$taxproduct;
+
+    my $tsr = $TSR_GENERAL;
+    my %hash = (
+      %base_item,
+      'LineNumber'      => 'S' . $billpkgnum,
+      'Revenue'         => $cust_bill_pkg->setup, # 4 decimal places
+      'Units'           => $cust_bill_pkg->units,
+      'TaxSitusRule'    => $tsr,
+      'TransTypeCode'   => $taxproduct,
+    );
+    push @items, \%hash;
+  }
+
+  @items;
+}
+
+sub make_taxlines {
+  my $self = shift;
+
+  my @elements;
+
+  my $cust_bill = shift;
+  if (!$cust_bill->invnum) {
+    die "FS::TaxEngine::suretax can't calculate taxes on a non-inserted invoice\n";
+  }
+  $self->{cust_bill} = $cust_bill;
+  my $cust_main = $cust_bill->cust_main;
+  my $country = $cust_main->bill_location->country;
+
+  my $invnum = $cust_bill->invnum;
+  if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
+    # don't even bother making the request
+    # (why are we even here, then? invoices with no line items
+    # should not be created)
+    return;
+  }
+
+  # assemble the request hash
+  my $request = $self->build_request;
+
+  warn "sending SureTax request\n" if $DEBUG;
+  my $request_json = $json->encode($request);
+  warn $request_json if $DEBUG > 1;
+
+  # We are targeting the "V05" interface:
+  # - accepts both telecom and general sales transactions
+  # - produces results broken down by "invoice" (Freeside line item)
+  my $ua = LWP::UserAgent->new;
+  my $http_response =  $ua->request(
+   POST "https://$host/Services/V05/SureTax.asmx/PostRequest",
+    [ request => $request_json ],
+    'Content-Type'  => 'application/x-www-form-urlencoded',
+    'Accept'        => 'application/json',
+  );
+
+  my $raw_response = $http_response->content;
+  warn "received response\n" if $DEBUG;
+  warn $raw_response if $DEBUG > 2;
+  my $response;
+  if ( $raw_response =~ /^<\?xml/ ) {
+    # an error message wrapped in a riddle inside an enigma inside an XML
+    # document...
+    $response = XMLin( $raw_response );
+    $raw_response = $response->{content};
+  }
+  $response = eval { $json->decode($raw_response) }
+    or die "$raw_response\n";
+
+  # documentation implies this might be necessary
+  $response = $response->{'d'} if exists $response->{'d'};
+
+  warn $json->encode($response) if $DEBUG > 1;
+ 
+  if ( $response->{Successful} ne 'Y' ) {
+    die $response->{HeaderMessage}."\n";
+  } else {
+    my $error = join("\n",
+      map { $_->{"LineNumber"}.': '. $_->{Message} }
+      @{ $response->{ItemMessages} }
+    );
+    die "$error\n" if $error;
+  }
+
+  return if !$response->{GroupList};
+  foreach my $taxable ( @{ $response->{GroupList} } ) {
+    # each member of this array here corresponds to what SureTax calls an
+    # "invoice" and we call a "line item". The invoice number is 
+    # cust_bill_pkg.billpkgnum.
+
+    my ($state, $geocode) = split(/\|/, $taxable->{StateCode});
+    foreach my $tax_element ( @{ $taxable->{TaxList} } ) {
+      # create a tax rate location if there isn't one yet
+      my $taxname = $tax_element->{TaxTypeDesc};
+      my $taxauth = substr($tax_element->{TaxTypeCode}, 0, 1);
+      my $tax_rate = FS::tax_rate->new({
+          data_vendor   => 'suretax',
+          taxname       => $taxname,
+          taxclassnum   => '',
+          taxauth       => $taxauth, # federal / state / city / district
+          geocode       => $geocode, # this is going to disambiguate all
+                                     # the taxes named "STATE SALES TAX", etc.
+          tax           => 0,
+          fee           => 0,
+      });
+      my $error = $tax_rate->find_or_insert;
+      die "error inserting tax_rate record for '$taxname': $error\n"
+        if $error;
+      $tax_rate = $tax_rate->replace_old;
+
+      my $tax_rate_location = FS::tax_rate_location->new({
+          data_vendor => 'suretax',
+          geocode     => $geocode,
+          state       => $state,
+          country     => $country,
+      });
+      $error = $tax_rate_location->find_or_insert;
+      die "error inserting tax_rate_location record for '$geocode': $error\n"
+        if $error;
+      $tax_rate_location = $tax_rate_location->replace_old;
+
+      push @elements, FS::cust_bill_pkg_tax_rate_location->new({
+          taxable_billpkgnum  => $taxable->{InvoiceNumber},
+          taxnum              => $tax_rate->taxnum,
+          taxtype             => 'FS::tax_rate',
+          taxratelocationnum  => $tax_rate_location->taxratelocationnum,
+          amount              => sprintf('%.2f', $tax_element->{TaxAmount}),
+      });
+    }
+  }
+  return @elements;
+}
+
+sub add_taxproduct {
+  my $class = shift;
+  my $desc = shift; # tax code and description, separated by a space.
+  if ($desc =~ s/^(\d{6}+) //) {
+    my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
+        'data_vendor' => 'suretax',
+        'taxproduct'  => $1,
+        'description' => $desc,
+    });
+    # $obj_or_error
+    return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
+  } else {
+    return "illegal suretax tax code '$desc'";
+  }
+}
+
+1;
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index b4340d0..ffc04ba 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -133,11 +133,11 @@ If you need to continue using the old Form 477 report, turn on the
     $conf->set($newname, 'location');
   }
 
-  # boolean enable_taxproducts is now enable_taxproducts = 'cch'
-  if ( $conf->exists('enable_taxproducts') and
-       $conf->config('enable_taxproducts') eq '' ) {
+  # boolean enable_taxproducts is now tax_data_vendor = 'cch'
+  if ( $conf->exists('enable_taxproducts') ) {
 
-    $conf->set('enable_taxproducts', 'cch');
+    $conf->delete('enable_taxproducts');
+    $conf->set('tax_data_vendor', 'cch');
 
   }
 
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index b6e4395..a5c4410 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -1275,7 +1275,7 @@ sub upgrade_tax_location {
   local $FS::cust_location::import = 1;
 
   my $conf = FS::Conf->new; # h_conf?
-  return if $conf->exists('enable_taxproducts'); #don't touch this case
+  return if $conf->config('tax_data_vendor'); #don't touch this case
   my $use_ship = $conf->exists('tax-ship_address');
   my $use_pkgloc = $conf->exists('tax-pkg_address');
 
diff --git a/FS/FS/cust_bill_pkg_tax_location.pm b/FS/FS/cust_bill_pkg_tax_location.pm
index 2ffc273..9a1f22a 100644
--- a/FS/FS/cust_bill_pkg_tax_location.pm
+++ b/FS/FS/cust_bill_pkg_tax_location.pm
@@ -144,6 +144,19 @@ Returns the cust_bill_pkg object for the I<taxable> charge.
 
 Returns the associated cust_location object
 
+=item taxname
+
+Returns the tax name (for populating the itemdesc field).
+
+=cut
+
+sub taxname {
+  my $self = shift;
+  my $cust_main_county = FS::cust_main_county->by_key($self->taxnum)
+    or return '';
+  $cust_main_county->taxname || 'Tax';
+}
+
 =item desc
 
 Returns a description for this tax line item constituent.  Currently this
diff --git a/FS/FS/cust_bill_pkg_tax_rate_location.pm b/FS/FS/cust_bill_pkg_tax_rate_location.pm
index 3e8098c..7ae5250 100644
--- a/FS/FS/cust_bill_pkg_tax_rate_location.pm
+++ b/FS/FS/cust_bill_pkg_tax_rate_location.pm
@@ -6,6 +6,7 @@ use FS::Record qw( qsearch qsearchs );
 use FS::cust_pkg;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
+use FS::tax_rate;
 
 =head1 NAME
 
@@ -130,6 +131,19 @@ Returns the associated cust_bill_pkg object
 
 Returns the associated tax_rate_location object
 
+=item taxname
+
+Returns the tax name (the itemdesc).
+
+=cut
+
+sub taxname {
+  my $self = shift;
+  my $tax_rate = FS::tax_rate->by_key($self->taxnum)
+    or return '';
+  $tax_rate->taxname;
+}
+
 =item desc
 
 Returns a description for this tax line item constituent.  Currently this
diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm
index 91bbf79..f63d86f 100644
--- a/FS/FS/cust_credit.pm
+++ b/FS/FS/cust_credit.pm
@@ -558,7 +558,7 @@ sub _upgrade_data {  # class method
   $class->_upgrade_otaker(%opts);
 
   if ( !FS::upgrade_journal->is_done('cust_credit__tax_link')
-      and !$conf->exists('enable_taxproducts') ) {
+      and !$conf->config('tax_data_vendor') ) {
     # RT#25458: fix credit line item applications that should refer to a 
     # specific tax allocation
     my @cust_credit_bill_pkg = qsearch({
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index 75dca34..f4c8045 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -1422,7 +1422,7 @@ sub _handle_taxes {
 
   return if ( $self->payby eq 'COMP' ); #dubious
 
-  if ( $conf->exists('enable_taxproducts')
+  if ( $conf->config('enable_taxproducts')
        && ( scalar($part_item->part_pkg_taxoverride)
             || $part_item->has_taxproduct
           )
diff --git a/FS/FS/cust_main/Billing_Discount.pm b/FS/FS/cust_main/Billing_Discount.pm
index d437740..b2852f6 100644
--- a/FS/FS/cust_main/Billing_Discount.pm
+++ b/FS/FS/cust_main/Billing_Discount.pm
@@ -110,6 +110,9 @@ by prepaying the most recent invoice for MONTHS.
 
 =cut
 
+# XXX this should work by creating a quotation; then we can finally retire
+# the "no_commit" option, which doesn't work with modern tax calculation
+
 sub discount_term_values {
   my $self = shift;
   my $term = shift;
diff --git a/FS/FS/part_event/Action/fee.pm b/FS/FS/part_event/Action/fee.pm
index f1d5891..a18cc33 100644
--- a/FS/FS/part_event/Action/fee.pm
+++ b/FS/FS/part_event/Action/fee.pm
@@ -40,7 +40,7 @@ sub _calc_fee {
       # they're definitely NOT linear and we haven't yet had a reason to 
       # make that case work.
       return $total if $self->option('setuptax') eq 'Y'
-                    or FS::Conf->new->exists('enable_taxproducts');
+                    or FS::Conf->new->config('tax_data_vendor');
 
       # estimate tax rate
       # false laziness with xmlhttp-calculate_taxes, cust_main::Billing, etc.
diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm
index ef14b4f..0ca52a0 100644
--- a/FS/FS/part_fee.pm
+++ b/FS/FS/part_fee.pm
@@ -523,6 +523,11 @@ sub has_taxproduct {
   return ($self->taxproductnum ? 1 : 0);
 }
 
+sub taxproduct { # compat w/ part_pkg
+  my $self = shift;
+  $self->part_pkg_taxproduct;
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm
index 8579020..67dd40e 100644
--- a/FS/FS/tax_rate.pm
+++ b/FS/FS/tax_rate.pm
@@ -386,10 +386,7 @@ 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.
+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
@@ -461,7 +458,7 @@ sub taxline_cch {
       $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
   }
 
-  my @tax_locations;
+  my @tax_links; # for output
   my %seen; # locationnum or pkgnum => 1
 
   my $taxable_cents = 0;
@@ -514,7 +511,7 @@ sub taxline_cch {
 
       # 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({
+      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
@@ -524,7 +521,7 @@ sub taxline_cch {
           'taxratelocationnum'    => $taxratelocationnum,
           'taxclass'              => $class,
       });
-      push @tax_locations, $tax_location;
+      push @tax_links, $tax_link;
 
       $taxable_cents += 100 * $taxable_charged;
       $tax_cents += $this_tax_cents;
@@ -579,7 +576,7 @@ sub taxline_cch {
         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({
+      my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({
           'taxnum'                => $self->taxnum,
           'taxtype'               => ref($self),
           'cents'                 => $this_tax_cents,
@@ -587,7 +584,7 @@ sub taxline_cch {
           'taxable_cust_bill_pkg' => $cust_bill_pkg,
           'taxratelocationnum'    => $taxratelocationnum,
       });
-      push @tax_locations, $tax_location;
+      push @tax_links, $tax_link;
 
       $taxable_units += $units;
       $tax_cents += $this_tax_cents;
@@ -614,7 +611,7 @@ sub taxline_cch {
   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?
+  foreach (@tax_links) { # can never require more than a single pass, yes?
     my $cents = $_->get('cents');
     if ( $extra_cents > 0 ) {
       $cents++;
@@ -623,26 +620,7 @@ sub taxline_cch {
     $_->set('amount', sprintf('%.2f', $cents/100));
   }
 
-  # 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;
+  return @tax_links;
 }
 
 sub _fatal_or_null {
diff --git a/FS/FS/tax_status.pm b/FS/FS/tax_status.pm
index f03eeca..5f7b50f 100644
--- a/FS/FS/tax_status.pm
+++ b/FS/FS/tax_status.pm
@@ -149,6 +149,12 @@ sub _upgrade_data {
     # P, Q, R: Canada, not yet supported
     # MED1/MED2: totally irrelevant to our users
   },
+  suretax => {
+    'R' => 'Residential',
+    'B' => 'Business',
+    'I' => 'Industrial',
+    'L' => 'Lifeline',
+  },
 );
 
 =back
diff --git a/httemplate/browse/part_pkg_taxproduct/avalara.html b/httemplate/browse/part_pkg_taxproduct/avalara.html
index e8da589..d7d8a60 100755
--- a/httemplate/browse/part_pkg_taxproduct/avalara.html
+++ b/httemplate/browse/part_pkg_taxproduct/avalara.html
@@ -61,8 +61,6 @@ my $conf = new FS::Conf;
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Edit package definitions');
 
-warn Dumper({ $cgi->Vars });
-
 # id: where to put the taxproductnum (in the parent document) after the user 
 # selects it
 $cgi->param('id') =~ /^([ \w]+)$/
diff --git a/httemplate/browse/part_pkg_taxproduct/suretax.html b/httemplate/browse/part_pkg_taxproduct/suretax.html
new file mode 100755
index 0000000..667c07e
--- /dev/null
+++ b/httemplate/browse/part_pkg_taxproduct/suretax.html
@@ -0,0 +1,172 @@
+<& /elements/header-popup.html, $title &>
+<& /browse/elements/browse.html,
+  'name_singular'  => 'tax product',
+  'html_form'      => include('.form', $category_code),
+  'query'          => {
+                        'table'     => 'part_pkg_taxproduct',
+                        'hashref'   => $hashref,
+                        'order_by'  => 'ORDER BY taxproduct',
+                      },
+  'count_query'    => $count_query,
+  'header'         => \@header,
+  'fields'         => \@fields,
+  'align'          => $align,
+  'links'          => [],
+  'link_onclicks'  => \@link_onclicks,
+  'nohtmlheader'   => 1,
+  'disable_total'  => 1,
+&>
+<style>
+input { float: right}
+</style>
+<script src="<% $fsurl %>elements/jquery.js"></script>
+<script>
+var category_labels = <% encode_json(\%category_labels) %>;
+$().ready(function() {
+  var new_taxproduct = $('#new_taxproduct');
+  var new_category_desc = $('#new_category_desc');
+  var new_taxproduct_desc = $('#new_taxproduct_desc');
+  var new_taxproduct_submit = $('#new_taxproduct_submit');
+
+  new_taxproduct.on('keyup', function() {
+    var curr_value = this.value || '';
+    var a = curr_value.match(/^\d{2}/);
+    var f = this.form;
+    if (a) { // there is a category code in the box
+      var category = a[0];
+      if (category_labels[category]) { // it matches an existing category
+        new_category_desc.val(category_labels[category]);
+        new_category_desc.prop('disabled', true);
+      } else {
+        new_category_desc.val('');
+        new_category_desc.prop('disabled', false);
+      }
+    } else {
+      new_category_desc.prop('disabled', true);
+    }
+    if (curr_value.match(/^\d{6}$/)) {
+      new_taxproduct_submit.prop('disabled', false);
+    } else {
+      new_taxproduct_submit.prop('disabled', true);
+    }
+  });
+
+  new_taxproduct_submit.on('click', function() {
+    select_taxproduct( -1,
+                       new_taxproduct.val()
+                          + ' '
+                          + new_category_desc.val()
+                          + ':'
+                          + new_taxproduct_desc.val()
+                     );
+  });
+});
+// post the values back to the parent form
+function select_taxproduct(taxproductnum, description) {
+  parent.document.getElementById('<% $id %>').value = taxproductnum;
+  parent.document.getElementById('<% $id %>_description').value = description;
+  parent.cClick();
+}
+  
+</script>  
+<DIV STYLE="width: 50%">
+<FORM NAME="myform">
+  <label for="new_taxproduct">New tax product code</label>
+  <input type="text" size="6" name="new_taxproduct" id="new_taxproduct">
+  <br>
+  <label for="new_category_desc">Category</label>
+  <input type="text" name="new_category_desc" id="new_category_desc" disabled=1>
+  <br>
+  <label for="new_taxproduct_desc">Product</label>
+  <input type="text" name="new_taxproduct_desc" id="new_taxproduct_desc">
+  <br>
+  <input type="button" id="new_taxproduct_submit" disabled=1 value="Add">
+</FORM>
+</DIV>
+<%shared>
+# populate dropdown
+
+# taxproduct is 6 digits: 2-digit category code + 4-digit detail code.
+# Description is also two parts, corresponding to those codes, separated with
+# a :.
+
+my (@category_codes, @taxproduct_codes, %category_labels, %taxproduct_labels);
+foreach my $row ( qsearch({
+  table   => 'part_pkg_taxproduct',
+  select  => 'DISTINCT substr(taxproduct, 1, 2) AS code, '.
+             "substring(description from '(.*):') AS label",
+  hashref => { data_vendor => 'suretax' },
+  }))
+{
+  $category_labels{$row->get('code')} = $row->get('label');
+}
+
+ at category_codes = sort {$a <=> $b} keys %category_labels;
+
+</%shared>
+<%def .form>
+% my ($category_code) = @_;
+<FORM ACTION="<% $cgi->url %>" METHOD="GET">
+<& /elements/select.html,
+  field       => 'category_code',
+  options     => \@category_codes,
+  labels      => \%category_labels,
+  curr_value  => $category_code,
+  onchange    => 'this.form.submit()',
+&>
+<& /elements/hidden.html,
+  field       => 'id',
+  curr_value  => $cgi->param('id'),
+&>
+</%def>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+$cgi->param('id') =~ /^\w+$/ or die "missing id parameter";
+my $id = $cgi->param('id');
+
+my $select_onclick = sub {
+  my $row = shift;
+  my $taxnum = $row->taxproductnum;
+  my $code = $row->taxproduct;
+  my $desc = $row->description;
+  "select_taxproduct('$taxnum', '$desc')";
+};
+
+my @menubar;
+my $title = 'Tax Products';
+
+my $hashref = { data_vendor => 'suretax' };
+
+my ($category_code, $taxproduct);
+if ( $cgi->param('category_code') =~ /^(\d+)$/ ) {
+  $category_code = $1;
+  $taxproduct = $category_code . '%';
+} else {
+  $taxproduct = '%';
+}
+
+$hashref->{taxproduct} = { op => 'LIKE', value => $taxproduct };
+
+my $count_query = "SELECT COUNT(*) FROM part_pkg_taxproduct ".
+                  "WHERE data_vendor = 'suretax' AND ".
+                  "taxproduct LIKE '$taxproduct'";
+
+my @fields = (
+  'taxproduct',
+  'description',
+  'note'
+);
+
+my @header = (
+  'Code',
+  'Description',
+  '',
+);
+
+my $align = 'lll';
+my @link_onclicks = ( $select_onclick, $select_onclick );
+
+</%init>
diff --git a/httemplate/config/config-view.cgi b/httemplate/config/config-view.cgi
index 0d16c5d..a2e9088 100644
--- a/httemplate/config/config-view.cgi
+++ b/httemplate/config/config-view.cgi
@@ -416,8 +416,9 @@ my @deleteable = qw( invoice_latexreturnaddress invoice_htmlreturnaddress );
 my %deleteable = map { $_ => 1 } @deleteable;
 
 my @sections = (qw(
-    required billing invoicing notification UI API self-service ticketing
-    network_monitoring username password session shell BIND telephony
+    required billing taxation invoicing notification UI API self-service
+    ticketing network_monitoring username password session shell BIND
+    telephony
   ), '', 'deprecated'
 );
 
diff --git a/httemplate/edit/part_fee.html b/httemplate/edit/part_fee.html
index 3399410..5f6dc38 100644
--- a/httemplate/edit/part_fee.html
+++ b/httemplate/edit/part_fee.html
@@ -35,7 +35,7 @@ die "access denied"
 
 my $conf = FS::Conf->new;
 my @tax_fields;
-if ( $conf->exists('enable_taxproducts') ) {
+if ( $conf->config('tax_data_vendor') ) {
   @tax_fields = (
     { field => 'taxproductnum', type => 'select-taxproduct' }
   );
diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi
index fbc19c3..bfa5d50 100755
--- a/httemplate/edit/part_pkg.cgi
+++ b/httemplate/edit/part_pkg.cgi
@@ -179,22 +179,28 @@
                        type  => 'hidden',
                        value => join(',', @taxproductnums),
                      },
-                     { field => 'taxproduct_select',
-                       type  => 'selectlayers',
-                       options => [ '(default)', @taxproductnums ],
-                       curr_value => '(default)',
-                       labels  => { ( '(default)' => '(default)' ),
-                                    map {($_=>$usage_class{$_})}
-                                    @taxproductnums
-                                  },
-                       layer_fields => \%taxproduct_fields,
-                       layer_values_callback => $taxproduct_values,
-                       layers_only  =>   !$taxproducts,
-                       cell_style   => ( !$taxproducts
-                                         ? 'display:none'
-                                         : ''
-                                       ),
+                     #{ field => 'taxproduct_select',
+                     #  type  => 'selectlayers',
+                     #  options => [ '(default)', @taxproductnums ],
+                     #  curr_value => '(default)',
+                     #  labels  => { ( '(default)' => '(default)' ),
+                     #               map {($_=>$usage_class{$_})}
+                     #               @taxproductnums
+                     #             },
+                     #  layer_fields => \%taxproduct_fields,
+                     #  layer_values_callback => $taxproduct_values,
+                     #  layers_only  =>   !$taxproducts,
+                     #  cell_style   => ( !$taxproducts
+                     #                    ? 'display:none'
+                     #                    : ''
+                     #                  ),
+                     #},
+                     { field => 'taxproductnum',
+                       type  => 'part_pkg-taxproducts',
+                       include_opt_callback =>
+                         sub { pkgpart => $_[0]->pkgpart },
                      },
+                      
 
                      { type  => 'tablebreak-tr-title',
                        value => 'Promotions', #better name?
@@ -414,7 +420,7 @@ my $agent_clone_extra_sql =
   ' ) ';
 
 my $conf = new FS::Conf;
-my $taxproducts = $conf->exists('enable_taxproducts');
+my $taxproducts = $conf->config('tax_data_vendor') ne '';
 
 my $fcc_opts = $conf->exists('part_pkg-show_fcc_options');
 
@@ -1120,9 +1126,9 @@ my $html_bottom = sub {
     '<SCRIPT TYPE="text/javascript">'.
       include('/elements/selectlayers.html', %selectlayers, 'js_only'=>1 );
 
-  $return .=
-    "taxproduct_selectchanged(document.getElementById('taxproduct_select'));\n"
-      if $taxproducts;
+#  $return .=
+#    "taxproduct_selectchanged(document.getElementById('taxproduct_select'));\n"
+#      if $taxproducts;
 
   $return .= '</SCRIPT>';
 
diff --git a/httemplate/edit/process/part_pkg.cgi b/httemplate/edit/process/part_pkg.cgi
index eda3f33..f3ee061 100755
--- a/httemplate/edit/process/part_pkg.cgi
+++ b/httemplate/edit/process/part_pkg.cgi
@@ -117,7 +117,7 @@ my $args_callback = sub {
     $error ||= "Illegal $param: $value"
       unless ( $value =~ /^\d*$/  );
     if (length($class)) {
-      $options{"usage_taxproductnum_$_"} = $value;
+      $options{"usage_taxproductnum_$class"} = $value;
     } else {
       $new->set('taxproductnum', $value);
     }
diff --git a/httemplate/edit/process/quick-charge.cgi b/httemplate/edit/process/quick-charge.cgi
index c1e7fc1..23eead4 100644
--- a/httemplate/edit/process/quick-charge.cgi
+++ b/httemplate/edit/process/quick-charge.cgi
@@ -74,7 +74,7 @@ if ( $param->{'pkgnum'} =~ /^(\d+)$/ ) { #modifying an existing one-time charge
  
   if ( $param->{'taxclass'} eq '(select)' ) {
     $error .= "Must select a tax class.  "
-      unless ($conf->exists('enable_taxproducts') &&
+      unless ($conf->config('tax_data_vendor') &&
                ( $override || $param->{taxproductnum} )
              );
     $cgi->param('taxclass', '');
@@ -122,7 +122,7 @@ if ( $param->{'pkgnum'} =~ /^(\d+)$/ ) { #modifying an existing one-time charge
 
   if ( $param->{'taxclass'} eq '(select)' ) {
     $error .= "Must select a tax class.  "
-      unless ($conf->exists('enable_taxproducts') &&
+      unless ($conf->config('tax_data_vendor'))
                ( $override || $param->{taxproductnum} )
              );
     $cgi->param('taxclass', '');
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index 9c9b2de..7d34d42 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -375,7 +375,7 @@ if( $curuser->access_right('Financial reports') ) {
   $report_financial{'A/R Aging'} = [ $fsurl.'search/report_receivables.html', 'Accounts Receivable Aging report' ];
   $report_financial{'Prepaid Income'} = [ $fsurl.'search/report_prepaid_income.html', 'Prepaid income (unearned revenue)  report' ];
 
-  my $taxproducts = $conf->exists('enable_taxproducts');
+  my $taxproducts = $conf->config('tax_data_vendor');
   $report_financial{'Tax Liability'. ($taxproducts ? ' (internal tax data)' : '')} = [ $fsurl.'search/report_tax.html', 'Tax liability report (internal tax data)' ];
   $report_financial{'Tax Liability (vendor tax data)'} = [ $fsurl.'search/report_newtax.html', 'Tax liability report (vendor tax data)' ]
     if $taxproducts;
@@ -458,7 +458,7 @@ tie my %tools_importing, 'Tie::IxHash',
   'Phone numbers (DIDs)' => [ $fsurl.'misc/phone_avail-import.html', '' ],
   'Call Detail Records (CDRs)' => [ $fsurl.'misc/cdr-import.html', '' ],
 ;
-if ( $conf->exists('enable_taxproducts') ) {
+if ( $conf->config('tax_data_vendor') eq 'cch' ) {
   if ( $conf->exists('taxdatadirectdownload') ) {
       $tools_importing{'Tax rates from vendor site'} =
       [ $fsurl.'misc/tax-fetch_and_import.cgi', '' ];
@@ -680,13 +680,13 @@ if ( $curuser->access_right('Configuration') ) {
   $config_billing{'separator2'} = ''; #its a separator!
 
   my $config_taxes_name = 'Locales and tax rates'.
-                          ( $conf->exists('enable_taxproducts')
+                          ( $conf->config('tax_data_vendor')
                             ? ' (internal tax class system)'
                             : ''
                           );
   $config_billing{$config_taxes_name}  = [ $fsurl.'browse/cust_main_county.cgi', 'Change tax rates, or break down a country into states, or a state into counties and assign different tax rates to each' ];
   $config_billing{'Tax rates (vendor data tax products system)'}  = [ $fsurl.'browse/tax_rate.cgi', 'Edit tax rates for the vendor data tax products system' ]
-     if $conf->exists('enable_taxproducts');
+     if $conf->config('tax_data_vendor');
   $config_billing{'Tax classes'} = [ $fsurl. 'browse/part_pkg_taxclass.html', 'Tax classes' ];
 
   if ( $conf->config('currencies') ) {
diff --git a/httemplate/elements/select-taxproduct.html b/httemplate/elements/select-taxproduct.html
index 07e5549..5feb71d 100644
--- a/httemplate/elements/select-taxproduct.html
+++ b/httemplate/elements/select-taxproduct.html
@@ -24,7 +24,7 @@ unless ( $description || ! $value ) {
 }
 
 my $conf = FS::Conf->new;
-my $vendor = lc($conf->config('enable_taxproducts'));
+my $vendor = lc($conf->config('tax_data_vendor'));
 my $onclick = $opt{onclick} ||
               "overlib( OLiframeContent('${p}/browse/part_pkg_taxproduct/$vendor.html?_type=select&id=${name}&taxproductnum='+document.getElementById('${name}').value, 1000, 400, 'tax_product_popup'), CAPTION, 'Select product', STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK); return false;";
 
diff --git a/httemplate/elements/tr-part_pkg-taxproducts.html b/httemplate/elements/tr-part_pkg-taxproducts.html
new file mode 100644
index 0000000..274dc3b
--- /dev/null
+++ b/httemplate/elements/tr-part_pkg-taxproducts.html
@@ -0,0 +1,34 @@
+<TR>
+  <TH COLSPAN=2>Tax products</TH>
+</TR>
+% foreach my $usage_class (@classes) {
+%   my $classnum = $usage_class->classnum;
+%   my $curr_value =
+%        $cgi->param("usage_taxproductnum_$classnum")
+%     || $pkg_options{"usage_taxproductnum_$classnum"}
+%     || '';
+<TR>
+  <TD><% $usage_class->classname %></TD>
+  <TD><& select-taxproduct.html,
+        %opt,
+        'field'       => $field.'_'.$classnum,
+        'curr_value'  => $curr_value
+      &>
+  </TD>
+</TR>
+% }
+<%init>
+my %opt = @_;
+my $field = delete($opt{field}) || 'taxproductnum';
+my $pkgpart = delete($opt{pkgpart});
+my $part_pkg = FS::part_pkg->by_key($pkgpart);
+my %pkg_options = $part_pkg->options;
+$pkg_options{'usage_taxproductnum_'} = $part_pkg->taxproductnum;
+
+my @classes = qsearch('usage_class', { 'disabled' => '' });
+unshift @classes,
+  FS::usage_class->new({ 'classnum' => '', 'classname' => '(default)', }),
+  FS::usage_class->new({ 'classnum' => 'setup', 'classname' => 'Setup', }),
+  FS::usage_class->new({ 'classnum' => 'recur', 'classname' => 'Recur', }),
+;
+</%init>
diff --git a/httemplate/elements/tr-select-tax_status.html b/httemplate/elements/tr-select-tax_status.html
index 9c2de15..1e0ea8a 100644
--- a/httemplate/elements/tr-select-tax_status.html
+++ b/httemplate/elements/tr-select-tax_status.html
@@ -17,7 +17,7 @@
 
 <%shared>
 my $conf = FS::Conf->new;
-my $vendor = $conf->config('enable_taxproducts');
+my $vendor = $conf->config('tax_data_vendor');
 </%shared>
 <%init>
 my %opt = @_;
diff --git a/httemplate/elements/tr-select-taxproduct.html b/httemplate/elements/tr-select-taxproduct.html
index 759d0c01..547f066 100644
--- a/httemplate/elements/tr-select-taxproduct.html
+++ b/httemplate/elements/tr-select-taxproduct.html
@@ -1,4 +1,4 @@
-% if ( $conf->exists('enable_taxproducts') ) { 
+% if ( $conf->config('tax_data_vendor') ) { # still not quite right
   <%include('tr-td-label.html', @_) %>
     <TD <% $cell_style %>><% include('select-taxproduct.html', @_) %></TD>
   </TR>
diff --git a/httemplate/misc/choose_tax_location.html b/httemplate/misc/choose_tax_location.html
index 9c5881f..2eb5ab9 100644
--- a/httemplate/misc/choose_tax_location.html
+++ b/httemplate/misc/choose_tax_location.html
@@ -38,7 +38,7 @@ my $conf = new FS::Conf;
 my $tax_engine = FS::TaxEngine->new;
 
 my %location;
-($location{data_vendor}) = $conf->config('enable_taxproducts');
+($location{data_vendor}) = $conf->config('tax_data_vendor');
 ($location{city})        = $cgi->param('city')        =~ /^([\w ]+)$/;
 ($location{state})       = $cgi->param('state')       =~ /^(\w+)$/;
 ($location{zip})         = $cgi->param('zip')         =~ /^([-\w ]+)$/;
diff --git a/httemplate/misc/tax-import.cgi b/httemplate/misc/tax-import.cgi
index 7e72c74..9581a79 100644
--- a/httemplate/misc/tax-import.cgi
+++ b/httemplate/misc/tax-import.cgi
@@ -60,10 +60,10 @@ die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Import');
 
 my $conf = FS::Conf->new;
-my $data_vendor = $conf->config('enable_taxproducts');
+my $data_vendor = $conf->config('tax_data_vendor');
 
 my %vendor_info = (
-  CCH => {
+  cch => {
     'num_files' => 6,
     'formats' => [ 'cch'        => 'CCH import (CSV)',
                    'cch-fixed'  => 'CCH import (fixed length)' ],
@@ -82,7 +82,7 @@ my %vendor_info = (
                    'detail filename',
                  ],
   },
-  Billsoft => {
+  billsoft => {
     'num_files' => 1,
     'formats' => [ 'billsoft-pcode' => 'Billsoft PCodes',
                    'billsoft-taxclass' => 'Tax classes',
diff --git a/httemplate/search/report_cust_pkg.html b/httemplate/search/report_cust_pkg.html
index f124f0f..dd1f97d 100755
--- a/httemplate/search/report_cust_pkg.html
+++ b/httemplate/search/report_cust_pkg.html
@@ -190,7 +190,7 @@
     <& /elements/tr-title.html, value => mt('Location search options') &>
 
 %   my @location_options = qw(cust nocust census nocensus);
-%   if ( $conf->exists('enable_taxproducts') ) {
+%   if ( $conf->config('tax_data_vendor') eq 'cch' ) {
 %     push @location_options, 'geocode', 'nogeocode';
 %   }
     <& /elements/tr-checkbox-multiple.html,
diff --git a/httemplate/view/cust_main/billing.html b/httemplate/view/cust_main/billing.html
index a16e8a5..0f794e3 100644
--- a/httemplate/view/cust_main/billing.html
+++ b/httemplate/view/cust_main/billing.html
@@ -71,7 +71,7 @@
     </TR>
 % }
 
-% if ( $conf->exists('enable_taxproducts') ) {
+% if ( $conf->config('tax_data_vendor') eq 'cch' ) {
 <TR>
   <TH ALIGN="right"><% mt('Tax location') |h %></TH>
 % my $tax_location = $conf->exists('tax-ship_address')

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

Summary of changes:
 FS/FS/Conf.pm                                      |   63 ++-
 FS/FS/Cursor.pm                                    |   32 +-
 FS/FS/TaxEngine.pm                                 |   89 +++--
 FS/FS/TaxEngine/cch.pm                             |   60 +--
 FS/FS/TaxEngine/internal.pm                        |   19 +-
 FS/FS/TaxEngine/suretax.pm                         |  421 ++++++++++++++++++++
 FS/FS/Upgrade.pm                                   |    8 +-
 FS/FS/cust_bill_pkg.pm                             |    2 +-
 FS/FS/cust_bill_pkg_tax_location.pm                |   13 +
 FS/FS/cust_bill_pkg_tax_rate_location.pm           |   14 +
 FS/FS/cust_credit.pm                               |    2 +-
 FS/FS/cust_main/Billing.pm                         |    2 +-
 FS/FS/cust_main/Billing_Discount.pm                |    3 +
 FS/FS/part_event/Action/fee.pm                     |    2 +-
 FS/FS/part_fee.pm                                  |    5 +
 FS/FS/tax_rate.pm                                  |   38 +-
 FS/FS/tax_status.pm                                |    6 +
 httemplate/browse/part_pkg_taxproduct/avalara.html |    2 -
 httemplate/browse/part_pkg_taxproduct/suretax.html |  172 ++++++++
 httemplate/config/config-view.cgi                  |    5 +-
 httemplate/edit/part_fee.html                      |    2 +-
 httemplate/edit/part_pkg.cgi                       |   44 +-
 httemplate/edit/process/part_pkg.cgi               |    2 +-
 httemplate/edit/process/quick-charge.cgi           |    4 +-
 httemplate/elements/menu.html                      |    8 +-
 httemplate/elements/select-taxproduct.html         |    2 +-
 httemplate/elements/tr-part_pkg-taxproducts.html   |   34 ++
 httemplate/elements/tr-select-tax_status.html      |    2 +-
 httemplate/elements/tr-select-taxproduct.html      |    2 +-
 httemplate/misc/choose_tax_location.html           |    2 +-
 httemplate/misc/tax-import.cgi                     |    6 +-
 httemplate/search/report_cust_pkg.html             |    2 +-
 httemplate/view/cust_main/billing.html             |    2 +-
 33 files changed, 884 insertions(+), 186 deletions(-)
 create mode 100644 FS/FS/TaxEngine/suretax.pm
 create mode 100755 httemplate/browse/part_pkg_taxproduct/suretax.html
 create mode 100644 httemplate/elements/tr-part_pkg-taxproducts.html




More information about the freeside-commits mailing list