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 = JSON->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
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.
40 my ($self, %opt) = @_;
42 my $oldAutoCommit = $FS::UID::AutoCommit;
43 local $FS::UID::AutoCommit = 0;
46 my $cust_bill = $self->{cust_bill};
47 my $cust_main = $cust_bill->cust_main;
49 # unfortunately we can't directly use the Business::Tax::Avalara get_tax()
50 # interface, because we have multiple customer addresses
53 # assemble invoice line items
55 # conventions we are using here:
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 :
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;
71 $address_seen{$sale->tax_locationnum} = 1;
74 'LineNo' => $sale->billpkgnum,
75 'DestinationCode' => $addr_code,
77 'ItemCode' => $item_code,
78 'TaxCode' => $taxproduct->taxproduct,
79 'Description' => $itemdesc,
80 'Qty' => $sale->quantity,
81 'Amount' => ($sale->setup + $sale->recur),
83 # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3',
89 # assemble address records for any cust_locations we used here, plus
91 # XXX these should just be separate config opts
92 my $our_address = join(' ',
93 $conf->config('company_address', $cust_main->agentnum)
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
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});
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',
152 'DocType' => 'SalesInvoice', # ???
153 'CustomerUsageType' => $cust_main->taxstatus,
154 # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo,
155 'Addresses' => \@addrs,
160 sub calculate_taxes {
161 $DB::single = 1; # XXX
164 my $cust_bill = shift;
165 if (!$cust_bill->invnum) {
166 warn "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice";
169 $self->{cust_bill} = $cust_bill;
171 my $invnum = $cust_bill->invnum;
172 if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
173 # don't even bother making the request
177 # instantiate gateway
178 eval "use Business::Tax::Avalara";
179 die "error loading Business::Tax::Avalara:\n$@\n" if $@;
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.
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),
197 # assemble the request hash
198 my $request = $self->build_request;
200 warn "sending Avalara tax request\n" if $DEBUG;
201 my $request_json = $json->encode($request);
202 warn $request_json if $DEBUG > 1;
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);
211 if ( $response->{ResultCode} ne 'Success' ) {
212 return "invoice#".$cust_bill->invnum.": ".
213 join("\n", @{ $response->{Messages} });
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};
223 my $taxname = $TaxDetail->{TaxName};
224 warn " $taxname\n" if $DEBUG > 1;
226 # create a tax line item
227 my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({
228 invnum => $cust_bill->invnum,
232 itemdesc => $taxname,
233 cust_bill_pkg_tax_rate_location => [],
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
238 my $tax_rate = FS::tax_rate->new({
239 data_vendor => 'avalara',
242 geocode => $TaxDetail->{JurisCode},
243 location => $TaxDetail->{JurisName},
247 my $error = $tax_rate->find_or_insert;
248 return "error inserting tax_rate record for '$taxname': $error\n"
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} : ''),
262 $error = $tax_rate_location->find_or_insert;
263 return "error inserting tax_rate_location record for ".
264 $TaxDetail->{JurisCode} .": $error\n"
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,
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
283 return [ values(%tax_item_named) ];
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',
293 'description' => $desc,
296 return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
298 return "illegal avalara tax code '$desc'";