package FS::TaxEngine::suretax; use strict; use base 'FS::TaxEngine'; use FS::Conf; use FS::Record qw(qsearch qsearchs dbh); use Cpanel::JSON::XS; 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 = Cpanel::JSON::XS->new->pretty(0)->shrink(1); our %taxproduct_cache; our $conf; 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', $agentnum) }; %taxproduct_cache = (); # assemble invoice line items my @lines = map { $self->build_item($_) } $cust_bill->cust_bill_pkg; return if !@lines; 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 => $date->year, DataMonth => 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; # use the _configured_ tax location as 'Zipcode' (respecting # tax-ship_address and tax-pkg_address configs) my $location = $cust_bill_pkg->tax_location; my ($zip, $plus4) = split('-', $location->zip); # and the _real_ location as 'P2PZipcode' my $svc_location = $location; if ( $cust_bill_pkg->pkgnum ) { $svc_location = $cust_bill_pkg->cust_pkg->cust_location; } my ($svc_zip, $svc_plus4) = split('-', $svc_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' => $zip, 'Plus4' => ($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'); my %hash = ( %base_item, 'LineNumber' => 'C' . $cdr->acctid, 'OrigNumber' => '', 'TermNumber' => '', 'BillToNumber' => '', 'TransDate' => $calldate, 'Revenue' => $cdr->rated_price, # 4 decimal places 'Units' => 0, # right? 'CallDuration' => $cdr->duration, 'TaxSitusRule' => $TSR_CALL_OTHER, 'TransTypeCode' => $taxproduct, ); # determine the tax situs rule; it's different (probably more accurate) # if the call has PSTN phone numbers at both ends if ( $cdr->charged_party =~ /^\d{10}$/ and $cdr->src =~ /^\d{10}$/ and $cdr->dst =~ /^\d{10}$/ and !$cdr->is_tollfree ) { $hash{TaxSitusRule} = $TSR_CALL_NPANXX; $hash{OrigNumber} = $cdr->src; $hash{TermNumber} = $cdr->dst; $hash{BillToNumber} = $cdr->charged_party; } 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; # when billing on cancellation there are no units my $units = $self->{cancel} ? 0 : $cust_bill_pkg->units; my %hash = ( %base_item, 'LineNumber' => 'R' . $billpkgnum, 'Revenue' => $recur_without_usage, # 4 decimal places 'Units' => $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; if (!$request) { warn "no taxable items in invoice; skipping SureTax request\n" if $DEBUG; return; } warn "encoding SureTax request\n" if $DEBUG; my $request_json = $json->encode($request); warn $request_json if $DEBUG > 1; my $host = $conf->config('suretax-hostname'); $host ||= 'testapi.taxrating.net'; warn "sending SureTax request\n" if $DEBUG; # 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', ); warn 'received SureTax response: '. $http_response->status_line. "\n" if $DEBUG; die $http_response->status_line. "\n" unless $http_response->is_success; my $raw_response = $http_response->content; 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}; } warn "decoding SureTax response\n" if $DEBUG; $response = eval { $json->decode($raw_response) } or die "Can't JSON-decode response: $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}; warn "creating FS objects from SureTax data\n" if $DEBUG; 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}), }); } } warn "TaxEngine/suretax.pm make_taxlines done; returning FS objects\n" if $DEBUG; 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;