SureTax, #31639, #33015, #34598
authorMark Wells <mark@freeside.biz>
Sat, 30 May 2015 22:12:07 +0000 (15:12 -0700)
committerMark Wells <mark@freeside.biz>
Sat, 30 May 2015 22:12:07 +0000 (15:12 -0700)
33 files changed:
FS/FS/Conf.pm
FS/FS/Cursor.pm
FS/FS/TaxEngine.pm
FS/FS/TaxEngine/cch.pm
FS/FS/TaxEngine/internal.pm
FS/FS/TaxEngine/suretax.pm [new file with mode: 0644]
FS/FS/Upgrade.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_bill_pkg_tax_location.pm
FS/FS/cust_bill_pkg_tax_rate_location.pm
FS/FS/cust_credit.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Discount.pm
FS/FS/part_event/Action/fee.pm
FS/FS/part_fee.pm
FS/FS/tax_rate.pm
FS/FS/tax_status.pm
httemplate/browse/part_pkg_taxproduct/avalara.html
httemplate/browse/part_pkg_taxproduct/suretax.html [new file with mode: 0755]
httemplate/config/config-view.cgi
httemplate/edit/part_fee.html
httemplate/edit/part_pkg.cgi
httemplate/edit/process/part_pkg.cgi
httemplate/edit/process/quick-charge.cgi
httemplate/elements/menu.html
httemplate/elements/select-taxproduct.html
httemplate/elements/tr-part_pkg-taxproducts.html [new file with mode: 0644]
httemplate/elements/tr-select-tax_status.html
httemplate/elements/tr-select-taxproduct.html
httemplate/misc/choose_tax_location.html
httemplate/misc/tax-import.cgi
httemplate/search/report_cust_pkg.html
httemplate/view/cust_main/billing.html

index f80f2d5..17a7c23 100644 (file)
@@ -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',
   },
index 67a98ea..faa15f9 100644 (file)
@@ -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>
index ac30eb1..4560142 100644 (file)
@@ -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 
index 4e6dbaf..fb34103 100644 (file)
@@ -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)
index 99535ad..f45bc08 100644 (file)
@@ -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 (file)
index 0000000..327a728
--- /dev/null
@@ -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;
index b4340d0..ffc04ba 100644 (file)
@@ -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');
 
   }
 
index b6e4395..a5c4410 100644 (file)
@@ -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');
 
index 2ffc273..9a1f22a 100644 (file)
@@ -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
index 3e8098c..7ae5250 100644 (file)
@@ -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
index 91bbf79..f63d86f 100644 (file)
@@ -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({
index 75dca34..f4c8045 100644 (file)
@@ -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
           )
index d437740..b2852f6 100644 (file)
@@ -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;
index f1d5891..a18cc33 100644 (file)
@@ -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.
index ef14b4f..0ca52a0 100644 (file)
@@ -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
index 8579020..67dd40e 100644 (file)
@@ -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 {
index f03eeca..5f7b50f 100644 (file)
@@ -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
index e8da589..d7d8a60 100755 (executable)
@@ -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 (executable)
index 0000000..667c07e
--- /dev/null
@@ -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');
+}
+
+@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>
index 0d16c5d..a2e9088 100644 (file)
@@ -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'
 );
 
index 3399410..5f6dc38 100644 (file)
@@ -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' }
   );
index fbc19c3..bfa5d50 100755 (executable)
                        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>';
 
index eda3f33..f3ee061 100755 (executable)
@@ -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);
     }
index c1e7fc1..23eead4 100644 (file)
@@ -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', '');
index 9c9b2de..7d34d42 100644 (file)
@@ -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') ) {
index 07e5549..5feb71d 100644 (file)
@@ -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 (file)
index 0000000..274dc3b
--- /dev/null
@@ -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>
index 9c2de15..1e0ea8a 100644 (file)
@@ -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 = @_;
index 759d0c0..547f066 100644 (file)
@@ -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>
index 9c5881f..2eb5ab9 100644 (file)
@@ -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 ]+)$/;
index 7e72c74..9581a79 100644 (file)
@@ -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',
index f124f0f..dd1f97d 100755 (executable)
     <& /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,
index a16e8a5..0f794e3 100644 (file)
@@ -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')