1 package FS::TaxEngine::avalara;
4 use base 'FS::TaxEngine';
6 use FS::Record qw(qsearch qsearchs dbh);
12 use Geo::StreetAddress::US;
15 our $json = Cpanel::JSON::XS->new->pretty(1);
24 FS::UID->install_callback( sub {
25 $conf = FS::Conf->new;
28 #sub cust_tax_locations {
30 # Avalara address standardization would be nice but isn't necessary
36 my ($self, %opt) = @_;
38 my $cust_bill = $self->{cust_bill};
39 my $cust_main = $cust_bill->cust_main;
41 # unfortunately we can't directly use the Business::Tax::Avalara get_tax()
42 # interface, because we have multiple customer addresses
45 # assemble invoice line items
47 # conventions we are using here:
50 # L#### = cust_location# (address code)
51 # L0 = company address
52 foreach my $sale ( $cust_bill->cust_bill_pkg ) {
53 my $part = $sale->part_X;
54 my $item_code = ($part->isa('FS::part_pkg') ? 'P'.$part->pkgpart :
57 my $addr_code = 'L'.$sale->tax_locationnum;
58 my $taxproductnum = $part->taxproductnum;
59 next unless $taxproductnum;
60 my $taxproduct = FS::part_pkg_taxproduct->by_key($taxproductnum);
61 my $itemdesc = $part->itemdesc || $part->pkg;
63 $address_seen{$sale->tax_locationnum} = 1;
66 'LineNo' => $sale->billpkgnum,
67 'DestinationCode' => $addr_code,
69 'ItemCode' => $item_code,
70 'TaxCode' => $taxproduct->taxproduct,
71 'Description' => $itemdesc,
72 'Qty' => $sale->quantity,
73 'Amount' => ($sale->setup + $sale->recur),
75 # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3',
80 # don't make the request unless there are some eligible line items
83 # assemble address records for any cust_locations we used here, plus
85 # XXX these should just be separate config opts
86 my $our_address = join(' ',
87 $conf->config('company_address', $cust_main->agentnum)
89 my $company_address = Geo::StreetAddress::US->parse_location($our_address);
90 if (!$company_address->{street}
91 or !$company_address->{city}
92 or !$company_address->{zip}) {
93 die "Your company address could not be parsed. Avalara tax calculation requires a company address with street, city, and zip code.\n";
96 my $address1 = join(' ', grep $_, @{$company_address}{qw(
97 number prefix street type suffix
99 my $address2 = join(' ', grep $_, @{$company_address}{qw(
100 sec_unit_type sec_unit_num
104 'AddressCode' => 'L0',
105 'Line1' => $address1,
106 'Line2' => $address2,
107 'City' => $company_address->{city},
108 'Region' => $company_address->{state},
109 'Country' => ($company_address->{country}
110 || $conf->config('countrydefault')
112 'PostalCode' => $company_address->{zip},
113 'Latitude' => ($conf->config('company_latitude') || ''),
114 'Longitude' => ($conf->config('company_longitude') || ''),
118 foreach my $locationnum (keys %address_seen) {
119 my $cust_location = FS::cust_location->by_key($locationnum);
121 'AddressCode' => 'L'.$locationnum,
122 'Line1' => $cust_location->address1,
123 'Line2' => $cust_location->address2,
125 'City' => $cust_location->city,
126 'Region' => $cust_location->state,
127 'Country' => $cust_location->country,
128 'PostalCode' => $cust_location->zip,
129 'Latitude' => $cust_location->latitude,
130 'Longitude' => $cust_location->longitude,
131 #'TaxRegionId', probably not necessary
136 my @avalara_conf = $conf->config('avalara-taxconfig');
138 # 2. user name (account number)
139 # 3. password (license)
140 # 4. test mode (1 to enable)
142 # create the top level object
143 my $date = DateTime->from_epoch(epoch => $self->{invoice_time});
144 my $doctype = $self->{estimate} ? 'SalesOrder' : 'SalesInvoice';
146 'CustomerCode' => $cust_main->custnum,
147 'DocDate' => $date->strftime('%Y-%m-%d'),
148 'CompanyCode' => $avalara_conf[0],
149 'Client' => "Freeside $FS::VERSION",
150 'DocCode' => $cust_bill->invnum,
151 'DetailLevel' => 'Tax',
153 'DocType' => $doctype,
154 'CustomerUsageType' => $cust_main->taxstatus,
155 # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo,
156 'Addresses' => \@addrs,
161 sub calculate_taxes {
162 $DB::single = 1; # XXX
165 my $cust_bill = shift;
166 if (!$cust_bill->invnum) {
167 # then something is wrong
168 die "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice\n";
170 $self->{cust_bill} = $cust_bill;
172 my $invnum = $cust_bill->invnum;
173 if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
174 # don't even bother making the request
178 # instantiate gateway
179 eval "use Business::Tax::Avalara";
180 die "error loading Business::Tax::Avalara:\n$@\n" if $@;
182 my @avalara_conf = $conf->config('avalara-taxconfig');
183 if (scalar @avalara_conf < 3) {
184 die "Your Avalara configuration is incomplete.
185 The 'avalara-taxconfig' parameter must have three rows: company code,
186 account number, and license key.
190 my $gateway = Business::Tax::Avalara->new(
191 customer_code => $self->{cust_main}->custnum,
192 company_code => $avalara_conf[0],
193 user_name => $avalara_conf[1],
194 password => $avalara_conf[2],
195 is_development => ($avalara_conf[3] ? 1 : 0),
198 # assemble the request hash
199 my $request = $self->build_request;
201 warn "no tax-eligible items on this invoice\n" if $DEBUG;
205 warn "sending Avalara tax request\n" if $DEBUG;
206 my $request_json = $json->encode($request);
207 warn $request_json if $DEBUG > 1;
209 my $response_json = $gateway->_make_request_json($request_json);
210 warn "received response\n" if $DEBUG;
211 warn $response_json if $DEBUG > 1;
212 my $response = $json->decode($response_json);
216 if ( $response->{ResultCode} ne 'Success' ) {
217 die "Avalara tax error on invoice#".$cust_bill->invnum.": ".
218 join("\n", @{ $response->{Messages} }).
221 warn "creating taxes for inv#$invnum\n" if $DEBUG > 1;
222 foreach my $TaxLine (@{ $response->{TaxLines} }) {
223 my $taxable_billpkgnum = $TaxLine->{LineNo};
224 warn " item #$taxable_billpkgnum\n" if $DEBUG > 1;
225 foreach my $TaxDetail (@{ $TaxLine->{TaxDetails} }) {
226 # in this case the tax doesn't apply (just informational)
227 next unless $TaxDetail->{Taxable};
229 my $taxname = $TaxDetail->{TaxName};
230 warn " $taxname\n" if $DEBUG > 1;
232 # create a tax line item
233 my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({
234 invnum => $cust_bill->invnum,
238 itemdesc => $taxname,
239 cust_bill_pkg_tax_rate_location => [],
241 # create a tax_rate record if there isn't one yet.
242 # we're not actually going to do anything with it, just tie related
244 my $tax_rate = FS::tax_rate->new({
245 data_vendor => 'avalara',
248 geocode => $TaxDetail->{JurisCode},
249 location => $TaxDetail->{JurisName},
253 my $error = $tax_rate->find_or_insert;
254 die "error inserting tax_rate record for '$taxname': $error\n"
256 $tax_rate = $tax_rate->replace_old; # get its taxnum if there wasn't one
258 # create a tax_rate_location record
259 my $tax_rate_location = FS::tax_rate_location->new({
260 data_vendor => 'avalara',
261 geocode => $TaxDetail->{JurisCode},
262 state => $TaxDetail->{Region},
263 city => ($TaxDetail->{JurisType} eq 'City' ?
264 $TaxDetail->{JurisName} : ''),
265 county => ($TaxDetail->{JurisType} eq 'County' ?
266 $TaxDetail->{JurisName} : ''),
269 $error = $tax_rate_location->find_or_insert;
270 die "error inserting tax_rate_location record for ".
271 $TaxDetail->{JurisCode} .": $error\n"
274 # create a link record
275 my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({
276 tax_cust_bill_pkg => $tax_item,
277 taxtype => 'FS::tax_rate',
278 taxnum => $tax_rate->taxnum,
279 taxratelocationnum => $tax_rate_location->taxratelocationnum,
280 amount => $TaxDetail->{Tax},
281 taxable_billpkgnum => $taxable_billpkgnum,
284 # append the tax link and increment the amount
285 push @{ $tax_item->get('cust_bill_pkg_tax_rate_location') }, $tax_link;
286 $tax_item->set('setup', $tax_item->get('setup') + $TaxDetail->{Tax});
287 } # foreach $TaxDetail
290 return [ values(%tax_item_named) ];
295 my $desc = shift; # tax code and description, separated by a space.
296 if ($desc =~ s/^(\w+) //) {
297 my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
298 'data_vendor' => 'avalara',
300 'description' => $desc,
303 return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
305 return "illegal avalara tax code '$desc'";