183555d88dcca6a64ef7bd65f6daa4be5b29dade
[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 = 2;
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 # XXX this is just here to avoid reworking the framework right now. By the
33 # 4.0 release, ALL tax calculations should be done after the invoice has 
34 # been inserted into the database.
35
36 # nothing to do here
37 sub add_sale {}
38
39 sub build_request {
40   my ($self, %opt) = @_;
41
42   my $oldAutoCommit = $FS::UID::AutoCommit;
43   local $FS::UID::AutoCommit = 0;
44   my $dbh = dbh;
45
46   my $cust_bill = $self->{cust_bill};
47   my $cust_main = $cust_bill->cust_main;
48
49   # unfortunately we can't directly use the Business::Tax::Avalara get_tax()
50   # interface, because we have multiple customer addresses
51   my %address_seen;
52  
53   # assemble invoice line items 
54   my @lines;
55   # conventions we are using here:
56   # P#### = part pkg#
57   # F#### = part_fee#
58   # L#### = cust_location# (address code)
59   # L0 = company address
60   foreach my $sale ( $cust_bill->cust_bill_pkg ) {
61     my $part = $sale->part_X;
62     my $item_code = ($part->isa('FS::part_pkg') ? 'P'.$part->pkgpart :
63                                                   'F'.$part->feepart
64                     );
65     my $addr_code = 'L'.$sale->tax_locationnum;
66     my $taxproductnum = $part->taxproductnum;
67     next unless $taxproductnum;
68     my $taxproduct = FS::part_pkg_taxproduct->by_key($taxproductnum);
69     my $itemdesc = $part->itemdesc || $part->pkg;
70
71     $address_seen{$sale->tax_locationnum} = 1;
72
73     my $line = {
74       'LineNo'            => $sale->billpkgnum,
75       'DestinationCode'   => $addr_code,
76       'OriginCode'        => 'L0',
77       'ItemCode'          => $item_code,
78       'TaxCode'           => $taxproduct->taxproduct,
79       'Description'       => $itemdesc,
80       'Qty'               => $sale->quantity,
81       'Amount'            => ($sale->setup + $sale->recur),
82       # also available:
83       # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3',
84       # 'TaxOverride'
85     };
86     push @lines, $line;
87   }
88
89   # assemble address records for any cust_locations we used here, plus
90   # the company address
91   # XXX these should just be separate config opts
92   my $our_address = join(' ', 
93     $conf->config('company_address', $cust_main->agentnum)
94   );
95   my $company_address = Geo::StreetAddress::US->parse_address($our_address);
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   return {
145     'CustomerCode'      => $cust_main->custnum,
146     'DocDate'           => $date->strftime('%Y-%m-%d'),
147     'CompanyCode'       => $avalara_conf[0],
148     'Client'            => "Freeside $FS::VERSION",
149     'DocCode'           => $cust_bill->invnum,
150     'DetailLevel'       => 'Tax',
151     'Commit'            => 'false',
152     'DocType'           => 'SalesInvoice', # ???
153     'CustomerUsageType' => $cust_main->taxstatus,
154     # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo,
155     'Addresses'         => \@addrs,
156     'Lines'             => \@lines,
157   };
158 }
159
160 sub calculate_taxes {
161   $DB::single = 1; # XXX
162   my $self = shift;
163
164   my $cust_bill = shift;
165   if (!$cust_bill->invnum) {
166     warn "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice";
167     return;
168   }
169   $self->{cust_bill} = $cust_bill;
170
171   my $invnum = $cust_bill->invnum;
172   if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
173     # don't even bother making the request
174     return [];
175   }
176
177   # instantiate gateway
178   eval "use Business::Tax::Avalara";
179   die "error loading Business::Tax::Avalara:\n$@\n" if $@;
180
181   my @avalara_conf = $conf->config('avalara-taxconfig');
182   if (scalar @avalara_conf < 3) {
183     die "Your Avalara configuration is incomplete.
184 The 'avalara-taxconfig' parameter must have three rows: company code, 
185 account number, and license key.
186 ";
187   }
188
189   my $gateway = Business::Tax::Avalara->new(
190     customer_code   => $self->{cust_main}->custnum,
191     company_code    => $avalara_conf[0],
192     user_name       => $avalara_conf[1],
193     password        => $avalara_conf[2],
194     is_development  => ($avalara_conf[3] ? 1 : 0),
195   );
196
197   # assemble the request hash
198   my $request = $self->build_request;
199
200   warn "sending Avalara tax request\n" if $DEBUG;
201   my $request_json = $json->encode($request);
202   warn $request_json if $DEBUG > 1;
203
204   my $response_json = $gateway->_make_request_json($request_json);
205   warn "received response\n" if $DEBUG;
206   warn $response_json if $DEBUG > 1;
207   my $response = $json->decode($response_json);
208  
209   my %tax_item_named;
210
211   if ( $response->{ResultCode} ne 'Success' ) {
212     return "invoice#".$cust_bill->invnum.": ".
213            join("\n", @{ $response->{Messages} });
214   }
215   warn "creating taxes for inv#$invnum\n" if $DEBUG > 1;
216   foreach my $TaxLine (@{ $response->{TaxLines} }) {
217     my $taxable_billpkgnum = $TaxLine->{LineNo};
218     warn "  item #$taxable_billpkgnum\n" if $DEBUG > 1;
219     foreach my $TaxDetail (@{ $TaxLine->{TaxDetails} }) {
220       # in this case the tax doesn't apply (just informational)
221       next unless $TaxDetail->{Taxable};
222
223       my $taxname = $TaxDetail->{TaxName};
224       warn "    $taxname\n" if $DEBUG > 1;
225
226       # create a tax line item
227       my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({
228           invnum    => $cust_bill->invnum,
229           pkgnum    => 0,
230           setup     => 0,
231           recur     => 0,
232           itemdesc  => $taxname,
233           cust_bill_pkg_tax_rate_location => [],
234       });
235       # create a tax_rate record if there isn't one yet.
236       # we're not actually going to do anything with it, just tie related
237       # taxes together.
238       my $tax_rate = FS::tax_rate->new({
239           data_vendor => 'avalara',
240           taxname     => $taxname,
241           taxclassnum => '',
242           geocode     => $TaxDetail->{JurisCode},
243           location    => $TaxDetail->{JurisName},
244           tax         => 0,
245           fee         => 0,
246       });
247       my $error = $tax_rate->find_or_insert;
248       return "error inserting tax_rate record for '$taxname': $error\n"
249         if $error;
250
251       # create a tax_rate_location record
252       my $tax_rate_location = FS::tax_rate_location->new({
253           data_vendor => 'avalara',
254           geocode     => $TaxDetail->{JurisCode},
255           state       => $TaxDetail->{Region},
256           city        => ($TaxDetail->{JurisType} eq 'City' ?
257                           $TaxDetail->{JurisName} : ''),
258           county      => ($TaxDetail->{JurisType} eq 'County' ?
259                           $TaxDetail->{JurisName} : ''),
260                         # country?
261       });
262       $error = $tax_rate_location->find_or_insert;
263       return "error inserting tax_rate_location record for ".
264               $TaxDetail->{JurisCode} .": $error\n"
265         if $error;
266
267       # create a link record
268       my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({
269           cust_bill_pkg       => $tax_item,
270           taxtype             => 'FS::tax_rate',
271           taxnum              => $tax_rate->taxnum,
272           taxratelocationnum  => $tax_rate_location->taxratelocationnum,
273           amount              => $TaxDetail->{Tax},
274           taxable_billpkgnum  => $taxable_billpkgnum,
275       });
276
277       # append the tax link and increment the amount
278       push @{ $tax_item->get('cust_bill_pkg_tax_rate_location') }, $tax_link;
279       $tax_item->set('setup', $tax_item->get('setup') + $TaxDetail->{Tax});
280     } # foreach $TaxDetail
281   } # foreach $TaxLine
282
283   return [ values(%tax_item_named) ];
284 }
285
286 sub add_taxproduct {
287   my $class = shift;
288   my $desc = shift; # tax code and description, separated by a space.
289   if ($desc =~ s/^(\w+) //) {
290     my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
291         'data_vendor' => 'avalara',
292         'taxproduct'  => $1,
293         'description' => $desc,
294     });
295     # $obj_or_error
296     return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
297   } else {
298     return "illegal avalara tax code '$desc'";
299   }
300 }
301
302 1;