default to a session cookie instead of setting an explicit timeout, weird timezone...
[freeside.git] / FS / FS / TaxEngine / avalara.pm
1 package FS::TaxEngine::avalara;
2
3 use strict;
4 use base 'FS::TaxEngine';
5 use FS::Conf;
6 use FS::Record qw(qsearch qsearchs dbh);
7 use FS::cust_pkg;
8 use FS::cust_location;
9 use FS::cust_bill_pkg;
10 use FS::tax_rate;
11 use Cpanel::JSON::XS;
12 use Geo::StreetAddress::US;
13
14 our $DEBUG = 0;
15 our $json = Cpanel::JSON::XS->new->pretty(1);
16
17 our $conf;
18
19 sub info {
20   { batch => 0,
21     override => 0 }
22 }
23
24 FS::UID->install_callback( sub {
25     $conf = FS::Conf->new;
26 });
27
28 #sub cust_tax_locations {
29 #}
30 # Avalara address standardization would be nice but isn't necessary
31
32 # nothing to do here
33 sub add_sale {}
34
35 sub build_request {
36   my ($self, %opt) = @_;
37
38   my $cust_bill = $self->{cust_bill};
39   my $cust_main = $cust_bill->cust_main;
40
41   # unfortunately we can't directly use the Business::Tax::Avalara get_tax()
42   # interface, because we have multiple customer addresses
43   my %address_seen;
44  
45   # assemble invoice line items 
46   my @lines;
47   # conventions we are using here:
48   # P#### = part pkg#
49   # F#### = part_fee#
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 :
55                                                   'F'.$part->feepart
56                     );
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;
62
63     $address_seen{$sale->tax_locationnum} = 1;
64
65     my $line = {
66       'LineNo'            => $sale->billpkgnum,
67       'DestinationCode'   => $addr_code,
68       'OriginCode'        => 'L0',
69       'ItemCode'          => $item_code,
70       'TaxCode'           => $taxproduct->taxproduct,
71       'Description'       => $itemdesc,
72       'Qty'               => $sale->quantity,
73       'Amount'            => ($sale->setup + $sale->recur),
74       # also available:
75       # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3',
76       # 'TaxOverride'
77     };
78     push @lines, $line;
79   }
80   # don't make the request unless there are some eligible line items
81   return '' if !@lines;
82
83   # assemble address records for any cust_locations we used here, plus
84   # the company address
85   # XXX these should just be separate config opts
86   my $our_address = join(' ', 
87     $conf->config('company_address', $cust_main->agentnum)
88   );
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";
94   }
95
96   my $address1 = join(' ', grep $_, @{$company_address}{qw(
97       number prefix street type suffix
98   )});
99   my $address2 = join(' ', grep $_, @{$company_address}{qw(
100       sec_unit_type sec_unit_num
101   )});
102   my @addrs = (
103     {
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')
111                               || 'US'),
112       'PostalCode'        => $company_address->{zip},
113       'Latitude'          => ($conf->config('company_latitude') || ''),
114       'Longitude'         => ($conf->config('company_longitude') || ''),
115     }
116   );
117
118   foreach my $locationnum (keys %address_seen) {
119     my $cust_location = FS::cust_location->by_key($locationnum);
120     my $addr = {
121       'AddressCode'       => 'L'.$locationnum,
122       'Line1'             => $cust_location->address1,
123       'Line2'             => $cust_location->address2,
124       'Line3'             => '',
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
132     };
133     push @addrs, $addr;
134   }
135
136   my @avalara_conf = $conf->config('avalara-taxconfig');
137   # 1. company code
138   # 2. user name (account number)
139   # 3. password (license)
140   # 4. test mode (1 to enable)
141
142   # create the top level object
143   my $date = DateTime->from_epoch(epoch => $self->{invoice_time});
144   my $doctype = $self->{estimate} ? 'SalesOrder' : 'SalesInvoice';
145   return {
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',
152     'Commit'            => 'false',
153     'DocType'           => $doctype,
154     'CustomerUsageType' => $cust_main->taxstatus,
155     # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo,
156     'Addresses'         => \@addrs,
157     'Lines'             => \@lines,
158   };
159 }
160
161 sub calculate_taxes {
162   $DB::single = 1; # XXX
163   my $self = shift;
164
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";
169   }
170   $self->{cust_bill} = $cust_bill;
171
172   my $invnum = $cust_bill->invnum;
173   if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
174     # don't even bother making the request
175     return [];
176   }
177
178   # instantiate gateway
179   eval "use Business::Tax::Avalara";
180   die "error loading Business::Tax::Avalara:\n$@\n" if $@;
181
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.
187 ";
188   }
189
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),
196   );
197
198   # assemble the request hash
199   my $request = $self->build_request;
200   if (!$request) {
201     warn "no tax-eligible items on this invoice\n" if $DEBUG;
202     return [];
203   }
204
205   warn "sending Avalara tax request\n" if $DEBUG;
206   my $request_json = $json->encode($request);
207   warn $request_json if $DEBUG > 1;
208
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);
213  
214   my %tax_item_named;
215
216   if ( $response->{ResultCode} ne 'Success' ) {
217     die "Avalara tax error on invoice#".$cust_bill->invnum.": ".
218            join("\n", @{ $response->{Messages} }).
219            "\n";
220   }
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};
228
229       my $taxname = $TaxDetail->{TaxName};
230       warn "    $taxname\n" if $DEBUG > 1;
231
232       # create a tax line item
233       my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({
234           invnum    => $cust_bill->invnum,
235           pkgnum    => 0,
236           setup     => 0,
237           recur     => 0,
238           itemdesc  => $taxname,
239           cust_bill_pkg_tax_rate_location => [],
240       });
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
243       # taxes together.
244       my $tax_rate = FS::tax_rate->new({
245           data_vendor => 'avalara',
246           taxname     => $taxname,
247           taxclassnum => '',
248           geocode     => $TaxDetail->{JurisCode},
249           location    => $TaxDetail->{JurisName},
250           tax         => 0,
251           fee         => 0,
252       });
253       my $error = $tax_rate->find_or_insert;
254       die "error inserting tax_rate record for '$taxname': $error\n"
255         if $error;
256       $tax_rate = $tax_rate->replace_old; # get its taxnum if there wasn't one
257
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} : ''),
267                         # country?
268       });
269       $error = $tax_rate_location->find_or_insert;
270       die "error inserting tax_rate_location record for ".
271               $TaxDetail->{JurisCode} .": $error\n"
272         if $error;
273
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,
282       });
283
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
288   } # foreach $TaxLine
289
290   return [ values(%tax_item_named) ];
291 }
292
293 sub add_taxproduct {
294   my $class = shift;
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',
299         'taxproduct'  => $1,
300         'description' => $desc,
301     });
302     # $obj_or_error
303     return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
304   } else {
305     return "illegal avalara tax code '$desc'";
306   }
307 }
308
309 1;