add Avalara tax status field to prospects, #25718
[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 JSON;
12 use Geo::StreetAddress::US;
13
14 our $DEBUG = 0;
15 our $json = JSON->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 $oldAutoCommit = $FS::UID::AutoCommit;
39   local $FS::UID::AutoCommit = 0;
40   my $dbh = dbh;
41
42   my $cust_bill = $self->{cust_bill};
43   my $cust_main = $cust_bill->cust_main;
44
45   # unfortunately we can't directly use the Business::Tax::Avalara get_tax()
46   # interface, because we have multiple customer addresses
47   my %address_seen;
48  
49   # assemble invoice line items 
50   my @lines;
51   # conventions we are using here:
52   # P#### = part pkg#
53   # F#### = part_fee#
54   # L#### = cust_location# (address code)
55   # L0 = company address
56   foreach my $sale ( $cust_bill->cust_bill_pkg ) {
57     my $part = $sale->part_X;
58     my $item_code = ($part->isa('FS::part_pkg') ? 'P'.$part->pkgpart :
59                                                   'F'.$part->feepart
60                     );
61     my $addr_code = 'L'.$sale->tax_locationnum;
62     my $taxproductnum = $part->taxproductnum;
63     next unless $taxproductnum;
64     my $taxproduct = FS::part_pkg_taxproduct->by_key($taxproductnum);
65     my $itemdesc = $part->itemdesc || $part->pkg;
66
67     $address_seen{$sale->tax_locationnum} = 1;
68
69     my $line = {
70       'LineNo'            => $sale->billpkgnum,
71       'DestinationCode'   => $addr_code,
72       'OriginCode'        => 'L0',
73       'ItemCode'          => $item_code,
74       'TaxCode'           => $taxproduct->taxproduct,
75       'Description'       => $itemdesc,
76       'Qty'               => $sale->quantity,
77       'Amount'            => ($sale->setup + $sale->recur),
78       # also available:
79       # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3',
80       # 'TaxOverride'
81     };
82     push @lines, $line;
83   }
84   # don't make the request unless there are some eligible line items
85   return '' if !@lines;
86
87   # assemble address records for any cust_locations we used here, plus
88   # the company address
89   # XXX these should just be separate config opts
90   my $our_address = join(' ', 
91     $conf->config('company_address', $cust_main->agentnum)
92   );
93   my $company_address = Geo::StreetAddress::US->parse_address($our_address);
94   my $address1 = join(' ', grep $_, @{$company_address}{qw(
95       number prefix street type suffix
96   )});
97   my $address2 = join(' ', grep $_, @{$company_address}{qw(
98       sec_unit_type sec_unit_num
99   )});
100   my @addrs = (
101     {
102       'AddressCode'       => 'L0',
103       'Line1'             => $address1,
104       'Line2'             => $address2,
105       'City'              => $company_address->{city},
106       'Region'            => $company_address->{state},
107       'Country'           => ($company_address->{country}
108                               || $conf->config('countrydefault')
109                               || 'US'),
110       'PostalCode'        => $company_address->{zip},
111       'Latitude'          => ($conf->config('company_latitude') || ''),
112       'Longitude'         => ($conf->config('company_longitude') || ''),
113     }
114   );
115
116   foreach my $locationnum (keys %address_seen) {
117     my $cust_location = FS::cust_location->by_key($locationnum);
118     my $addr = {
119       'AddressCode'       => 'L'.$locationnum,
120       'Line1'             => $cust_location->address1,
121       'Line2'             => $cust_location->address2,
122       'Line3'             => '',
123       'City'              => $cust_location->city,
124       'Region'            => $cust_location->state,
125       'Country'           => $cust_location->country,
126       'PostalCode'        => $cust_location->zip,
127       'Latitude'          => $cust_location->latitude,
128       'Longitude'         => $cust_location->longitude,
129       #'TaxRegionId', probably not necessary
130     };
131     push @addrs, $addr;
132   }
133
134   my @avalara_conf = $conf->config('avalara-taxconfig');
135   # 1. company code
136   # 2. user name (account number)
137   # 3. password (license)
138   # 4. test mode (1 to enable)
139
140   # create the top level object
141   my $date = DateTime->from_epoch(epoch => $self->{invoice_time});
142   my $doctype = $self->{estimate} ? 'SalesOrder' : 'SalesInvoice';
143   return {
144     'CustomerCode'      => $cust_main->custnum,
145     'DocDate'           => $date->strftime('%Y-%m-%d'),
146     'CompanyCode'       => $avalara_conf[0],
147     'Client'            => "Freeside $FS::VERSION",
148     'DocCode'           => $cust_bill->invnum,
149     'DetailLevel'       => 'Tax',
150     'Commit'            => 'false',
151     'DocType'           => $doctype,
152     'CustomerUsageType' => $cust_main->taxstatus,
153     # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo,
154     'Addresses'         => \@addrs,
155     'Lines'             => \@lines,
156   };
157 }
158
159 sub calculate_taxes {
160   $DB::single = 1; # XXX
161   my $self = shift;
162
163   my $cust_bill = shift;
164   if (!$cust_bill->invnum) {
165     warn "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice";
166     return;
167   }
168   $self->{cust_bill} = $cust_bill;
169
170   my $invnum = $cust_bill->invnum;
171   if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
172     # don't even bother making the request
173     return [];
174   }
175
176   # instantiate gateway
177   eval "use Business::Tax::Avalara";
178   die "error loading Business::Tax::Avalara:\n$@\n" if $@;
179
180   my @avalara_conf = $conf->config('avalara-taxconfig');
181   if (scalar @avalara_conf < 3) {
182     die "Your Avalara configuration is incomplete.
183 The 'avalara-taxconfig' parameter must have three rows: company code, 
184 account number, and license key.
185 ";
186   }
187
188   my $gateway = Business::Tax::Avalara->new(
189     customer_code   => $self->{cust_main}->custnum,
190     company_code    => $avalara_conf[0],
191     user_name       => $avalara_conf[1],
192     password        => $avalara_conf[2],
193     is_development  => ($avalara_conf[3] ? 1 : 0),
194   );
195
196   # assemble the request hash
197   my $request = $self->build_request;
198   if (!$request) {
199     warn "no tax-eligible items on this invoice\n" if $DEBUG;
200     return [];
201   }
202
203   warn "sending Avalara tax request\n" if $DEBUG;
204   my $request_json = $json->encode($request);
205   warn $request_json if $DEBUG > 1;
206
207   my $response_json = $gateway->_make_request_json($request_json);
208   warn "received response\n" if $DEBUG;
209   warn $response_json if $DEBUG > 1;
210   my $response = $json->decode($response_json);
211  
212   my %tax_item_named;
213
214   if ( $response->{ResultCode} ne 'Success' ) {
215     return "invoice#".$cust_bill->invnum.": ".
216            join("\n", @{ $response->{Messages} });
217   }
218   warn "creating taxes for inv#$invnum\n" if $DEBUG > 1;
219   foreach my $TaxLine (@{ $response->{TaxLines} }) {
220     my $taxable_billpkgnum = $TaxLine->{LineNo};
221     warn "  item #$taxable_billpkgnum\n" if $DEBUG > 1;
222     foreach my $TaxDetail (@{ $TaxLine->{TaxDetails} }) {
223       # in this case the tax doesn't apply (just informational)
224       next unless $TaxDetail->{Taxable};
225
226       my $taxname = $TaxDetail->{TaxName};
227       warn "    $taxname\n" if $DEBUG > 1;
228
229       # create a tax line item
230       my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({
231           invnum    => $cust_bill->invnum,
232           pkgnum    => 0,
233           setup     => 0,
234           recur     => 0,
235           itemdesc  => $taxname,
236           cust_bill_pkg_tax_rate_location => [],
237       });
238       # create a tax_rate record if there isn't one yet.
239       # we're not actually going to do anything with it, just tie related
240       # taxes together.
241       my $tax_rate = FS::tax_rate->new({
242           data_vendor => 'avalara',
243           taxname     => $taxname,
244           taxclassnum => '',
245           geocode     => $TaxDetail->{JurisCode},
246           location    => $TaxDetail->{JurisName},
247           tax         => 0,
248           fee         => 0,
249       });
250       my $error = $tax_rate->find_or_insert;
251       return "error inserting tax_rate record for '$taxname': $error\n"
252         if $error;
253       $tax_rate = $tax_rate->replace_old; # get its taxnum if there wasn't one
254
255       # create a tax_rate_location record
256       my $tax_rate_location = FS::tax_rate_location->new({
257           data_vendor => 'avalara',
258           geocode     => $TaxDetail->{JurisCode},
259           state       => $TaxDetail->{Region},
260           city        => ($TaxDetail->{JurisType} eq 'City' ?
261                           $TaxDetail->{JurisName} : ''),
262           county      => ($TaxDetail->{JurisType} eq 'County' ?
263                           $TaxDetail->{JurisName} : ''),
264                         # country?
265       });
266       $error = $tax_rate_location->find_or_insert;
267       return "error inserting tax_rate_location record for ".
268               $TaxDetail->{JurisCode} .": $error\n"
269         if $error;
270
271       # create a link record
272       my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({
273           tax_cust_bill_pkg   => $tax_item,
274           taxtype             => 'FS::tax_rate',
275           taxnum              => $tax_rate->taxnum,
276           taxratelocationnum  => $tax_rate_location->taxratelocationnum,
277           amount              => $TaxDetail->{Tax},
278           taxable_billpkgnum  => $taxable_billpkgnum,
279       });
280
281       # append the tax link and increment the amount
282       push @{ $tax_item->get('cust_bill_pkg_tax_rate_location') }, $tax_link;
283       $tax_item->set('setup', $tax_item->get('setup') + $TaxDetail->{Tax});
284     } # foreach $TaxDetail
285   } # foreach $TaxLine
286
287   return [ values(%tax_item_named) ];
288 }
289
290 sub add_taxproduct {
291   my $class = shift;
292   my $desc = shift; # tax code and description, separated by a space.
293   if ($desc =~ s/^(\w+) //) {
294     my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
295         'data_vendor' => 'avalara',
296         'taxproduct'  => $1,
297         'description' => $desc,
298     });
299     # $obj_or_error
300     return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
301   } else {
302     return "illegal avalara tax code '$desc'";
303   }
304 }
305
306 1;