Compliance Solutions <http://csilongwood.com/> integration, RT#75262
authorIvan Kohler <ivan@freeside.biz>
Tue, 20 Jun 2017 23:32:00 +0000 (16:32 -0700)
committerIvan Kohler <ivan@freeside.biz>
Tue, 20 Jun 2017 23:32:00 +0000 (16:32 -0700)
FS/FS/Conf.pm
FS/FS/TaxEngine.pm
FS/FS/TaxEngine/compliance_solutions.pm [new file with mode: 0644]
FS/FS/TaxEngine/suretax.pm
httemplate/browse/part_pkg_taxproduct/compliance_solutions.html [new file with mode: 0644]
httemplate/browse/part_pkg_taxproduct/suretax.html

index a53fedb..5ba6fce 100644 (file)
@@ -2468,7 +2468,7 @@ and customer address. Include units.',
     'section'     => 'taxation',
     'description' => 'Tax data vendor you are using.',
     'type'        => 'select',
-    'select_enum' => [ '', 'cch', 'billsoft', 'avalara', 'suretax' ],
+    'select_enum' => [ '', 'cch', 'billsoft', 'avalara', 'suretax', 'compliance_solutions' ],
   },
 
   {
@@ -2541,6 +2541,20 @@ and customer address. Include units.',
     'per_agent'   => 1,
   },
 
+  {
+    'key'         => 'compliance_solutions-access_code',
+    'section'     => 'taxation',
+    'description' => 'Access code for <a href="http://csilongwood.com/">Compliance Solutions</a> tax rating service',
+    'type'        => 'text',
+  },
+  {
+    'key'         => 'compliance_solutions-regulatory_code',
+    'section'     => 'taxation',
+    'description' => 'Compliance Solutions regulatory status.',
+    'type'        => 'select',
+    'select_enum' => [ '', 'ILEC', 'IXC', 'CLEC', 'VOIP', 'ISP', 'Wireless' ],
+    'per_agent'   => 1,
+  },
 
   {
     'key'         => 'welcome_msgnum',
index 4560142..e92bf76 100644 (file)
@@ -124,6 +124,8 @@ sub calculate_taxes {
   if ( !@raw_taxlines ) {
     return;
   } elsif ( !ref $raw_taxlines[0] ) { # error message
+    #this isn't actually handled by our caller... better for make_taxlines to 
+    # die, that'll be caught be the eval around us in cust_main/Billing.pm
     return $raw_taxlines[0];
   }
 
@@ -296,7 +298,10 @@ a string on failure.
 
 sub add_taxproduct {
   my $class = shift;
-  "$class does not allow manually adding taxproducts";
+  #my $classname = ref($class);
+  #my $vendor = (split('::',$classname))[2];
+  my $vendor = ref($class) || $class;
+  "$vendor does not allow manually adding taxproducts";
 }
 
 =item transfer_batch (batch-style only)
diff --git a/FS/FS/TaxEngine/compliance_solutions.pm b/FS/FS/TaxEngine/compliance_solutions.pm
new file mode 100644 (file)
index 0000000..92ca2ce
--- /dev/null
@@ -0,0 +1,330 @@
+package FS::TaxEngine::compliance_solutions;
+
+#some false laziness w/ suretax... uses/based on cch data?  or just imitating
+# parts of their interface?
+
+use strict;
+use base qw( FS::TaxEngine );
+use FS::Conf;
+use FS::Record qw( dbh ); #qw( qsearch qsearchs dbh);
+use Data::Dumper;
+use Date::Format;
+use Cpanel::JSON::XS;
+use SOAP::Lite;
+
+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 = Cpanel::JSON::XS->new->pretty(1);
+
+our %taxproduct_cache;
+
+our $conf;
+
+FS::UID->install_callback( sub {
+    $conf = FS::Conf->new;
+    # should we enable conf caching here?
+});
+
+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_input {
+  my( $self, $cust_bill ) = @_;
+
+  my $cust_main = $cust_bill->cust_main;
+
+  %taxproduct_cache = ();
+
+  # assemble invoice line items 
+  my @lines = map { $self->build_input_item($_, $cust_bill, $cust_main) }
+                  $cust_bill->cust_bill_pkg;
+
+  return if !@lines;
+
+  return \@lines;
+
+}
+
+sub build_input_item {
+  my( $self, $cust_bill_pkg, $cust_bill, $cust_main ) = @_;
+
+  # 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;
+
+  ###
+  # Usage charges
+  ###
+
+  # 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 ) {
+      push @items, {
+        $self->generic_item($cust_bill, $cust_main),
+        record_type   => 'C',
+        unique_id     => 'cdr ' . $cdr->acctid.
+                         ' cust_bill_pkg '.$cust_bill_pkg->billpkgnum, 
+        productcode   => substr($taxproduct,0,4),
+        servicecode   => substr($taxproduct,4,3),
+        orig_Num      => $cdr->src,
+        term_Num      => $cdr->dst,
+        bill_Num      => $cdr->charged_party,
+        charge_amount => $cdr->rated_price, # 4 decimal places
+        minutes       => sprintf('%.1f', $cdr->billsec / 60 ),
+      };
+
+    } # 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;
+
+    # when billing on cancellation there are no units
+    my $units = $self->{cancel} ? 0 : $cust_bill_pkg->units;
+    unshift @items, {
+      $self->generic_item($cust_bill, $cust_main),
+      record_type     => 'S',
+      unique_id       => 'cust_bill_pkg '. $cust_bill_pkg->billpkgnum. ' recur',
+      charge_amount   => $recur_without_usage,
+      location_a      => $cust_bill_pkg->tax_location->zip,
+      productcode     => substr($taxproduct,0,4),
+      servicecode     => substr($taxproduct,4,3),
+      units           => $units,
+    };
+  }
+
+  ###
+  # Setup charge
+  ###
+
+  if ( $cust_bill_pkg->setup > 0 ) {
+    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;
+
+    unshift @items, {
+      $self->generic_item($cust_bill, $cust_main),
+      record_type     => 'S',
+      unique_id       => 'cust_bill_pkg '. $cust_bill_pkg->billpkgnum. ' setup',
+      charge_amount   => $cust_bill_pkg->setup,
+      location_a      => $cust_bill_pkg->tax_location->zip,
+      productcode     => substr($taxproduct,0,4),
+      servicecode     => substr($taxproduct,4,3),
+      units           => $cust_bill_pkg->units,
+    };
+  }
+
+  return @items;
+
+}
+
+sub generic_item {
+  my( $self, $cust_bill, $cust_main ) = @_;
+
+  warn 'regcode '. $self->{regcode} if $DEBUG;
+
+  (
+    account_number            => $cust_bill->custnum,
+    customer_type             => ( $cust_main->company =~ /\S/ ? '01' : '00' ),
+    invoice_date              => time2str('%Y%m%d', $cust_bill->_date),
+    invoice_number            => $cust_bill->invnum,
+    provider                  => $self->{regcode},
+    safe_harbor_override_flag => 'N',
+    exempt_code               => $cust_main->tax,
+  );
+
+}
+
+sub make_taxlines {
+  my( $self, $cust_bill ) = @_;
+
+  die "compliance_solutions-regulatory_code setting is not configured\n"
+    unless $conf->config('compliance_solutions-regulatory_code', $cust_bill->cust_main->agentnum);
+
+  $self->{regcode} = $REGCODE{ $conf->config('compliance_solutions-regulatory_code', $cust_bill->cust_main->agentnum) };
+
+  warn 'regcode '. $self->{regcode} if $DEBUG;
+
+  # assemble the request hash
+  my $input = $self->build_input($cust_bill);
+  if (!$input) {
+    warn "no taxable items in invoice; skipping Compliance Solutions request\n" if $DEBUG;
+    return;
+  }
+
+  warn "sending Compliance Solutions request\n" if $DEBUG;
+  my $request_json = $json->encode(
+    {
+      'access_code' => $conf->config('compliance_solutions-access_code'),
+      'reference'   => 'Invoice #'. $cust_bill->invnum,
+      'input'       => $input,
+    }
+  );
+  warn $request_json if $DEBUG > 1;
+
+  my $soap = SOAP::Lite->service("http://tcms1.csilongwood.com/cgi-bin/taxcalc.wsdl");
+
+  $soap->soapversion('1.2'); #service appears to be flaky with the default 1.1
+
+  my $results = $soap->tax_rate($request_json);
+
+  my %json_result = %{ $json->decode( $results ) };
+  warn Dumper(%json_result) if $DEBUG > 1;
+
+  # handle $results is empty / API/connection failure?
+
+  # status OK
+  unless ( $json_result{status} =~ /^\s*OK\s*$/i ) {
+    warn Dumper($json_result{error_codes}) unless $DEBUG > 1;
+    die 'Compliance Solutions returned status '. $json_result{status}.
+           "; see log for error_codes detail\n";
+  }
+
+  # transmission_error No errors.
+  unless ( $json_result{transmission_error} =~ /^\s*No\s+errors\.\s*$/i ) {
+    warn Dumper($json_result{error_codes}) unless $DEBUG > 1;
+    die 'Compliance Solutions returned transmission_error '. $json_result{transmission_error}.
+           "; see log for error_codes detail\n";
+  }
+
+
+  # error_codes / No errors (for all records... check them individually in loop?
+
+  my @elements = ();
+
+  #handle the response
+  foreach my $tax_data ( @{ $json_result{tax_data} } ) {
+
+    # create a tax rate location if there isn't one yet
+    my $taxname = $tax_data->{descript};
+    my $tax_rate = FS::tax_rate->new({
+        data_vendor   => 'compliance solutions',
+        taxname       => $taxname,
+        taxclassnum   => '',
+        taxauth       => $tax_data->{'taxauthtype'}, # federal / state / city / district
+        geocode       => $tax_data->{'geocode'},
+        tax           => 0, #not necessary because we query for rates on the
+        fee           => 0, # fly and only store this for the name -> code map??
+    });
+    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 => 'compliance solutions',
+        state       => $tax_data->{'state'},
+        country     => $tax_data->{'country'},
+        geocode     => $tax_data->{'geocode'},
+    });
+    $error = $tax_rate_location->find_or_insert;
+    die "error inserting tax_rate_location record: $error\n"
+      if $error;
+    $tax_rate_location = $tax_rate_location->replace_old;
+
+    #unique id: a cust_bill_pkg (setup/recur) or cdr record
+
+    my $taxable_billpkgnum = '';
+    if ( $tax_data->{'unique_id'} =~ /^cust_bill_pkg (\d+)/ ) {
+      $taxable_billpkgnum = $1;
+    } elsif ( $tax_data->{'unique_id'} =~ /^cdr (\d+) cust_bill_pkg (\d+)$/ ) {
+      $taxable_billpkgnum = $2;
+    } else {
+      die 'unparseable unique_id '. $tax_data->{'unique_id'};
+    }
+
+    push @elements, FS::cust_bill_pkg_tax_rate_location->new({
+      taxable_billpkgnum  => $taxable_billpkgnum,
+      taxnum              => $tax_rate->taxnum,
+      taxtype             => 'FS::tax_rate',
+      taxratelocationnum  => $tax_rate_location->taxratelocationnum,
+      amount              => sprintf('%.2f', $tax_data->{taxamount}),
+    });
+
+  }
+
+  return @elements;
+}
+
+sub add_taxproduct {
+  my $class = shift;
+  my $desc = shift; # tax code and description, separated by a space.
+  if ($desc =~ s/^(\w{7}+) //) {
+    my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
+        'data_vendor' => 'compliance_solutions',
+        'taxproduct'  => $1,
+        'description' => $desc,
+    });
+    # $obj_or_error
+    return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
+  } else {
+    return "illegal compliance solutions tax code '$desc'";
+  }
+}
+
+1;
index e18b668..0f6c69d 100644 (file)
@@ -77,7 +77,7 @@ sub build_request {
   ($self->{bill_zip}, $self->{bill_plus4}) =
     split('-', $cust_main->bill_location->zip);
 
-  $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code') };
+  $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code', $agentnum) };
 
   %taxproduct_cache = ();
 
diff --git a/httemplate/browse/part_pkg_taxproduct/compliance_solutions.html b/httemplate/browse/part_pkg_taxproduct/compliance_solutions.html
new file mode 100644 (file)
index 0000000..cf07b31
--- /dev/null
@@ -0,0 +1,163 @@
+<& /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,
+&>
+<script>
+$().ready(function() {
+  var new_taxproduct = $('#new_taxproduct');
+  var new_taxproduct2 = $('#new_taxproduct2');
+//  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 || '';
+//    if (curr_value.match(/^\d{7}$/)) {
+//      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_taxproduct2.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>  
+<BR>
+
+Please contact <a href="http://csilongwood.com/" target="_blank">Compliance Solutions</a> for a full list of your product and service codes.<BR><BR>
+
+<FORM NAME="myform">
+  <FONT SIZE="+1"><B><% emt('Add tax product') %></B></FONT>
+  <% ntable('#cccccc', 2) %>
+    <& /elements/tr-input-text.html,
+      'label'     => emt('Product code'),
+      'field'     => 'new_taxproduct',
+      'id'        => 'new_taxproduct',
+      'size'      => 4,
+      'maxlength' => 4,
+    &>
+    <& /elements/tr-input-text.html,
+      'label'     => emt('Service code'),
+      'field'     => 'new_taxproduct2',
+      'id'        => 'new_taxproduct2',
+      'size'      => 3,
+      'maxlength' => 3,
+    &>
+    <& /elements/tr-input-text.html,
+      'label'     => emt('Product name'),
+      'field'     => 'new_taxproduct_desc',
+      'id'        => 'new_taxproduct_desc',
+    &>
+  </table>
+%#  <input type="button" id="new_taxproduct_submit" disabled=1 value="Add">
+  <input type="button" id="new_taxproduct_submit" value="Add">
+</FORM>
+
+<& /elements/footer-popup.html &>
+<%shared>
+# populate dropdown
+
+#taxproduct is 7 digits: 4-digit (well, alpha) productcode + 3-digit servicecode
+# Description is also two parts, corresponding to those codes, separated with
+# a :.
+
+my (@productcodes, @servicecodes);
+foreach my $row ( qsearch({
+  table   => 'part_pkg_taxproduct',
+  select  => 'DISTINCT substr(taxproduct, 1, 4) AS productcode ',
+  hashref => { data_vendor => 'compliance_solutions' },
+  }))
+{
+  push @productcodes, $row->{productcode};
+}
+
+foreach my $row ( qsearch({
+  table   => 'part_pkg_taxproduct',
+  select  => 'DISTINCT substr(taxproduct, 4, 3) AS servicecode ',
+  hashref => { data_vendor => 'compliance_solutions' },
+  }))
+{
+  push @servicecodes, $row->{servicecode};
+}
+
+</%shared>
+<%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 $desc = $row->taxproduct . ' ' . $row->description;
+  "select_taxproduct('$taxnum', '$desc')";
+};
+
+my @menubar;
+my $title = 'Tax Products';
+
+my $hashref = { data_vendor => 'compliance_solutions' };
+
+#my ($category_code, $taxproduct);
+#if ( $cgi->param('category_code') =~ /^(\d+)$/ ) {
+#  $category_code = $1;
+#  $taxproduct = $category_code . '%';
+#} else {
+#  $taxproduct = '%';
+#}
+my $taxproduct = '%';
+
+$hashref->{taxproduct} = { op => 'LIKE', value => $taxproduct };
+
+my $count_query = "SELECT COUNT(*) FROM part_pkg_taxproduct ".
+                  "WHERE data_vendor = 'compliance_solutions' 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 178062c..4605c1f 100755 (executable)
@@ -91,6 +91,7 @@ function select_taxproduct(taxproductnum, description) {
   </table>
   <input type="button" id="new_taxproduct_submit" disabled=1 value="Add">
 </FORM>
+<& /elements/footer-popup.html &>
 <%shared>
 # populate dropdown