X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2FTaxEngine%2Favalara.pm;fp=FS%2FFS%2FTaxEngine%2Favalara.pm;h=183555d88dcca6a64ef7bd65f6daa4be5b29dade;hb=7516e3da0f17eeecba27219ef96a8b5f46af2083;hp=0000000000000000000000000000000000000000;hpb=f31a9212ab3835b815aa87a86cca3b19babcaaff;p=freeside.git diff --git a/FS/FS/TaxEngine/avalara.pm b/FS/FS/TaxEngine/avalara.pm new file mode 100644 index 000000000..183555d88 --- /dev/null +++ b/FS/FS/TaxEngine/avalara.pm @@ -0,0 +1,302 @@ +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 JSON; +use Geo::StreetAddress::US; + +our $DEBUG = 2; +our $json = JSON->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 + +# XXX this is just here to avoid reworking the framework right now. By the +# 4.0 release, ALL tax calculations should be done after the invoice has +# been inserted into the database. + +# nothing to do here +sub add_sale {} + +sub build_request { + my ($self, %opt) = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + 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; + } + + # 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_address($our_address); + 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}); + 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' => 'SalesInvoice', # ??? + '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) { + warn "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice"; + return; + } + $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; + + 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' ) { + return "invoice#".$cust_bill->invnum.": ". + join("\n", @{ $response->{Messages} }); + } + 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; + return "error inserting tax_rate record for '$taxname': $error\n" + if $error; + + # 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; + return "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({ + 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;