package FS::TaxEngine::avalara; use strict; use base 'FS::TaxEngine'; use FS::Conf; use FS::Record qw(qsearch qsearchs dbh); use FS::cust_pkg; use FS::cust_location; use FS::cust_bill_pkg; use FS::tax_rate; use Cpanel::JSON::XS; use Geo::StreetAddress::US; our $DEBUG = 0; our $json = Cpanel::JSON::XS->new->pretty(1); our $conf; sub info { { batch => 0, override => 0 } } FS::UID->install_callback( sub { $conf = FS::Conf->new; }); #sub cust_tax_locations { #} # Avalara address standardization would be nice but isn't necessary # nothing to do here sub add_sale {} sub build_request { my ($self, %opt) = @_; my $cust_bill = $self->{cust_bill}; my $cust_main = $cust_bill->cust_main; # unfortunately we can't directly use the Business::Tax::Avalara get_tax() # interface, because we have multiple customer addresses my %address_seen; # assemble invoice line items my @lines; # conventions we are using here: # P#### = part pkg# # F#### = part_fee# # L#### = cust_location# (address code) # L0 = company address foreach my $sale ( $cust_bill->cust_bill_pkg ) { my $part = $sale->part_X; my $item_code = ($part->isa('FS::part_pkg') ? 'P'.$part->pkgpart : 'F'.$part->feepart ); my $addr_code = 'L'.$sale->tax_locationnum; my $taxproductnum = $part->taxproductnum; next unless $taxproductnum; my $taxproduct = FS::part_pkg_taxproduct->by_key($taxproductnum); my $itemdesc = $part->itemdesc || $part->pkg; $address_seen{$sale->tax_locationnum} = 1; my $line = { 'LineNo' => $sale->billpkgnum, 'DestinationCode' => $addr_code, 'OriginCode' => 'L0', 'ItemCode' => $item_code, 'TaxCode' => $taxproduct->taxproduct, 'Description' => $itemdesc, 'Qty' => $sale->quantity, 'Amount' => ($sale->setup + $sale->recur), # also available: # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3', # 'TaxOverride' }; push @lines, $line; } # don't make the request unless there are some eligible line items return '' if !@lines; # assemble address records for any cust_locations we used here, plus # the company address # XXX these should just be separate config opts my $our_address = join(' ', $conf->config('company_address', $cust_main->agentnum) ); my $company_address = Geo::StreetAddress::US->parse_location($our_address); if (!$company_address->{street} or !$company_address->{city} or !$company_address->{zip}) { die "Your company address could not be parsed. Avalara tax calculation requires a company address with street, city, and zip code.\n"; } my $address1 = join(' ', grep $_, @{$company_address}{qw( number prefix street type suffix )}); my $address2 = join(' ', grep $_, @{$company_address}{qw( sec_unit_type sec_unit_num )}); my @addrs = ( { 'AddressCode' => 'L0', 'Line1' => $address1, 'Line2' => $address2, 'City' => $company_address->{city}, 'Region' => $company_address->{state}, 'Country' => ($company_address->{country} || $conf->config('countrydefault') || 'US'), 'PostalCode' => $company_address->{zip}, 'Latitude' => ($conf->config('company_latitude') || ''), 'Longitude' => ($conf->config('company_longitude') || ''), } ); foreach my $locationnum (keys %address_seen) { my $cust_location = FS::cust_location->by_key($locationnum); my $addr = { 'AddressCode' => 'L'.$locationnum, 'Line1' => $cust_location->address1, 'Line2' => $cust_location->address2, 'Line3' => '', 'City' => $cust_location->city, 'Region' => $cust_location->state, 'Country' => $cust_location->country, 'PostalCode' => $cust_location->zip, 'Latitude' => $cust_location->latitude, 'Longitude' => $cust_location->longitude, #'TaxRegionId', probably not necessary }; push @addrs, $addr; } my @avalara_conf = $conf->config('avalara-taxconfig'); # 1. company code # 2. user name (account number) # 3. password (license) # 4. test mode (1 to enable) # create the top level object my $date = DateTime->from_epoch(epoch => $self->{invoice_time}); my $doctype = $self->{estimate} ? 'SalesOrder' : 'SalesInvoice'; return { 'CustomerCode' => $cust_main->custnum, 'DocDate' => $date->strftime('%Y-%m-%d'), 'CompanyCode' => $avalara_conf[0], 'Client' => "Freeside $FS::VERSION", 'DocCode' => $cust_bill->invnum, 'DetailLevel' => 'Tax', 'Commit' => 'false', 'DocType' => $doctype, 'CustomerUsageType' => $cust_main->taxstatus, # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo, 'Addresses' => \@addrs, 'Lines' => \@lines, }; } sub calculate_taxes { $DB::single = 1; # XXX my $self = shift; my $cust_bill = shift; if (!$cust_bill->invnum) { # then something is wrong die "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice\n"; } $self->{cust_bill} = $cust_bill; my $invnum = $cust_bill->invnum; if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) { # don't even bother making the request return []; } # instantiate gateway eval "use Business::Tax::Avalara"; die "error loading Business::Tax::Avalara:\n$@\n" if $@; my @avalara_conf = $conf->config('avalara-taxconfig'); if (scalar @avalara_conf < 3) { die "Your Avalara configuration is incomplete. The 'avalara-taxconfig' parameter must have three rows: company code, account number, and license key. "; } my $gateway = Business::Tax::Avalara->new( customer_code => $self->{cust_main}->custnum, company_code => $avalara_conf[0], user_name => $avalara_conf[1], password => $avalara_conf[2], is_development => ($avalara_conf[3] ? 1 : 0), ); # assemble the request hash my $request = $self->build_request; if (!$request) { warn "no tax-eligible items on this invoice\n" if $DEBUG; return []; } warn "sending Avalara tax request\n" if $DEBUG; my $request_json = $json->encode($request); warn $request_json if $DEBUG > 1; my $response_json = $gateway->_make_request_json($request_json); warn "received response\n" if $DEBUG; warn $response_json if $DEBUG > 1; my $response = $json->decode($response_json); my %tax_item_named; if ( $response->{ResultCode} ne 'Success' ) { die "Avalara tax error on invoice#".$cust_bill->invnum.": ". join("\n", @{ $response->{Messages} }). "\n"; } warn "creating taxes for inv#$invnum\n" if $DEBUG > 1; foreach my $TaxLine (@{ $response->{TaxLines} }) { my $taxable_billpkgnum = $TaxLine->{LineNo}; warn " item #$taxable_billpkgnum\n" if $DEBUG > 1; foreach my $TaxDetail (@{ $TaxLine->{TaxDetails} }) { # in this case the tax doesn't apply (just informational) next unless $TaxDetail->{Taxable}; my $taxname = $TaxDetail->{TaxName}; warn " $taxname\n" if $DEBUG > 1; # create a tax line item my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({ invnum => $cust_bill->invnum, pkgnum => 0, setup => 0, recur => 0, itemdesc => $taxname, cust_bill_pkg_tax_rate_location => [], }); # create a tax_rate record if there isn't one yet. # we're not actually going to do anything with it, just tie related # taxes together. my $tax_rate = FS::tax_rate->new({ data_vendor => 'avalara', taxname => $taxname, taxclassnum => '', geocode => $TaxDetail->{JurisCode}, location => $TaxDetail->{JurisName}, 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; # get its taxnum if there wasn't one # create a tax_rate_location record my $tax_rate_location = FS::tax_rate_location->new({ data_vendor => 'avalara', geocode => $TaxDetail->{JurisCode}, state => $TaxDetail->{Region}, city => ($TaxDetail->{JurisType} eq 'City' ? $TaxDetail->{JurisName} : ''), county => ($TaxDetail->{JurisType} eq 'County' ? $TaxDetail->{JurisName} : ''), # country? }); $error = $tax_rate_location->find_or_insert; die "error inserting tax_rate_location record for ". $TaxDetail->{JurisCode} .": $error\n" if $error; # create a link record my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ tax_cust_bill_pkg => $tax_item, taxtype => 'FS::tax_rate', taxnum => $tax_rate->taxnum, taxratelocationnum => $tax_rate_location->taxratelocationnum, amount => $TaxDetail->{Tax}, taxable_billpkgnum => $taxable_billpkgnum, }); # append the tax link and increment the amount push @{ $tax_item->get('cust_bill_pkg_tax_rate_location') }, $tax_link; $tax_item->set('setup', $tax_item->get('setup') + $TaxDetail->{Tax}); } # foreach $TaxDetail } # foreach $TaxLine return [ values(%tax_item_named) ]; } sub add_taxproduct { my $class = shift; my $desc = shift; # tax code and description, separated by a space. if ($desc =~ s/^(\w+) //) { my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({ 'data_vendor' => 'avalara', 'taxproduct' => $1, 'description' => $desc, }); # $obj_or_error return $part_pkg_taxproduct->insert || $part_pkg_taxproduct; } else { return "illegal avalara tax code '$desc'"; } } 1;