From: Ivan Kohler Date: Tue, 20 Jun 2017 23:32:00 +0000 (-0700) Subject: Compliance Solutions integration, RT#75262 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=e5b2660aefd03bc4b32386e1c38c53814c002c8d Compliance Solutions integration, RT#75262 --- diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index a53fedb8e..5ba6fce69 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -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 Compliance Solutions 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', diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm index 45601429a..e92bf768c 100644 --- a/FS/FS/TaxEngine.pm +++ b/FS/FS/TaxEngine.pm @@ -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 index 000000000..92ca2ce02 --- /dev/null +++ b/FS/FS/TaxEngine/compliance_solutions.pm @@ -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; diff --git a/FS/FS/TaxEngine/suretax.pm b/FS/FS/TaxEngine/suretax.pm index e18b66866..0f6c69de3 100644 --- a/FS/FS/TaxEngine/suretax.pm +++ b/FS/FS/TaxEngine/suretax.pm @@ -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 index 000000000..cf07b3189 --- /dev/null +++ b/httemplate/browse/part_pkg_taxproduct/compliance_solutions.html @@ -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, +&> + +
+ +Please contact Compliance Solutions for a full list of your product and service codes.

+ +
+ <% emt('Add tax product') %> + <% 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', + &> + +%# + +
+ +<& /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}; +} + + +<%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 ); + + diff --git a/httemplate/browse/part_pkg_taxproduct/suretax.html b/httemplate/browse/part_pkg_taxproduct/suretax.html index 178062c4a..4605c1f34 100755 --- a/httemplate/browse/part_pkg_taxproduct/suretax.html +++ b/httemplate/browse/part_pkg_taxproduct/suretax.html @@ -91,6 +91,7 @@ function select_taxproduct(taxproductnum, description) { +<& /elements/footer-popup.html &> <%shared> # populate dropdown