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
36 my ($self, %opt) = @_;
38 my $oldAutoCommit = $FS::UID::AutoCommit;
39 local $FS::UID::AutoCommit = 0;
42 my $cust_bill = $self->{cust_bill};
43 my $cust_main = $cust_bill->cust_main;
45 # unfortunately we can't directly use the Business::Tax::Avalara get_tax()
46 # interface, because we have multiple customer addresses
49 # assemble invoice line items
51 # conventions we are using here:
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 :
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;
67 $address_seen{$sale->tax_locationnum} = 1;
70 'LineNo' => $sale->billpkgnum,
71 'DestinationCode' => $addr_code,
73 'ItemCode' => $item_code,
74 'TaxCode' => $taxproduct->taxproduct,
75 'Description' => $itemdesc,
76 'Qty' => $sale->quantity,
77 'Amount' => ($sale->setup + $sale->recur),
79 # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3',
84 # don't make the request unless there are some eligible line items
87 # assemble address records for any cust_locations we used here, plus
89 # XXX these should just be separate config opts
90 my $our_address = join(' ',
91 $conf->config('company_address', $cust_main->agentnum)
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
97 my $address2 = join(' ', grep $_, @{$company_address}{qw(
98 sec_unit_type sec_unit_num
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')
110 'PostalCode' => $company_address->{zip},
111 'Latitude' => ($conf->config('company_latitude') || ''),
112 'Longitude' => ($conf->config('company_longitude') || ''),
116 foreach my $locationnum (keys %address_seen) {
117 my $cust_location = FS::cust_location->by_key($locationnum);
119 'AddressCode' => 'L'.$locationnum,
120 'Line1' => $cust_location->address1,
121 'Line2' => $cust_location->address2,
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
134 my @avalara_conf = $conf->config('avalara-taxconfig');
136 # 2. user name (account number)
137 # 3. password (license)
138 # 4. test mode (1 to enable)
140 # create the top level object
141 my $date = DateTime->from_epoch(epoch => $self->{invoice_time});
142 my $doctype = $self->{estimate} ? 'SalesOrder' : 'SalesInvoice';
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',
151 'DocType' => $doctype,
152 'CustomerUsageType' => $cust_main->taxstatus,
153 # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo,
154 'Addresses' => \@addrs,
159 sub calculate_taxes {
160 $DB::single = 1; # XXX
163 my $cust_bill = shift;
164 if (!$cust_bill->invnum) {
165 warn "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice";
168 $self->{cust_bill} = $cust_bill;
170 my $invnum = $cust_bill->invnum;
171 if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
172 # don't even bother making the request
176 # instantiate gateway
177 eval "use Business::Tax::Avalara";
178 die "error loading Business::Tax::Avalara:\n$@\n" if $@;
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.
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),
196 # assemble the request hash
197 my $request = $self->build_request;
199 warn "no tax-eligible items on this invoice\n" if $DEBUG;
203 warn "sending Avalara tax request\n" if $DEBUG;
204 my $request_json = $json->encode($request);
205 warn $request_json if $DEBUG > 1;
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);
214 if ( $response->{ResultCode} ne 'Success' ) {
215 return "invoice#".$cust_bill->invnum.": ".
216 join("\n", @{ $response->{Messages} });
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};
226 my $taxname = $TaxDetail->{TaxName};
227 warn " $taxname\n" if $DEBUG > 1;
229 # create a tax line item
230 my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({
231 invnum => $cust_bill->invnum,
235 itemdesc => $taxname,
236 cust_bill_pkg_tax_rate_location => [],
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
241 my $tax_rate = FS::tax_rate->new({
242 data_vendor => 'avalara',
245 geocode => $TaxDetail->{JurisCode},
246 location => $TaxDetail->{JurisName},
250 my $error = $tax_rate->find_or_insert;
251 return "error inserting tax_rate record for '$taxname': $error\n"
253 $tax_rate = $tax_rate->replace_old; # get its taxnum if there wasn't one
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} : ''),
266 $error = $tax_rate_location->find_or_insert;
267 return "error inserting tax_rate_location record for ".
268 $TaxDetail->{JurisCode} .": $error\n"
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,
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
287 return [ values(%tax_item_named) ];
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',
297 'description' => $desc,
300 return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
302 return "illegal avalara tax code '$desc'";