still need HTML::TokeParser for Washington sales tax, #33917, from #32459
[freeside.git] / FS / FS / Misc / Geo.pm
1 package FS::Misc::Geo;
2
3 use strict;
4 use base qw( Exporter );
5 use vars qw( $DEBUG @EXPORT_OK $conf );
6 use LWP::UserAgent;
7 use HTTP::Request;
8 use HTTP::Request::Common qw( GET POST );
9 use HTML::TokeParser;
10 use JSON;
11 use URI::Escape 3.31;
12 use Data::Dumper;
13 use FS::Conf;
14 use Locale::Country;
15
16 FS::UID->install_callback( sub {
17   $conf = new FS::Conf;
18 } );
19
20 $DEBUG = 0;
21
22 @EXPORT_OK = qw( get_district );
23
24 =head1 NAME
25
26 FS::Misc::Geo - routines to fetch geographic information
27
28 =head1 CLASS METHODS
29
30 =over 4
31
32 =item get_censustract_ffiec LOCATION YEAR
33
34 Given a location hash (see L<FS::location_Mixin>) and a census map year,
35 returns a census tract code (consisting of state, county, and tract 
36 codes) or an error message.
37
38 =cut
39
40 sub get_censustract_ffiec {
41   my $class = shift;
42   my $location = shift;
43   my $year  = shift;
44   $year ||= 2013;
45
46   if ( length($location->{country}) and uc($location->{country}) ne 'US' ) {
47     return '';
48   }
49
50   warn Dumper($location, $year) if $DEBUG;
51
52   # the old FFIEC geocoding service was shut down December 1, 2014.
53   # welcome to the future.
54   my $url = 'https://geomap.ffiec.gov/FFIECGeocMap/GeocodeMap1.aspx/GetGeocodeData';
55   # build the single-line query
56   my $single_line = join(', ', $location->{address1},
57                                $location->{city},
58                                $location->{state}
59                         );
60   my $hashref = { sSingleLine => $single_line, iCensusYear => $year };
61   my $request = POST( $url,
62     'Content-Type' => 'application/json; charset=utf-8',
63     'Accept' => 'application/json',
64     'Content' => encode_json($hashref)
65   );
66
67   my $ua = new LWP::UserAgent;
68   my $res = $ua->request( $request );
69
70   warn $res->as_string
71     if $DEBUG > 2;
72
73   if (!$res->is_success) {
74
75     die "Census tract lookup error: ".$res->message;
76
77   }
78
79   local $@;
80   my $content = eval { decode_json($res->content) };
81   die "Census tract JSON error: $@\n" if $@;
82
83   if ( !exists $content->{d}->{sStatus} ) {
84     die "Census tract response is missing a status indicator.\nThis is an FFIEC problem.\n";
85   }
86   if ( $content->{d}->{sStatus} eq 'Y' ) {
87     # success
88     # this also contains the (partial) standardized address, correct zip 
89     # code, coordinates, etc., and we could get all of them, but right now
90     # we only want the census tract
91     my $tract = join('', $content->{d}->{sStateCode},
92                          $content->{d}->{sCountyCode},
93                          $content->{d}->{sTractCode});
94     return $tract;
95
96   } else {
97
98     my $error = $content->{d}->{sMsg}
99             ||  'FFIEC lookup failed, but with no status message.';
100     die "$error\n";
101
102   }
103 }
104
105 #sub get_district_methods {
106 #  ''         => '',
107 #  'wa_sales' => 'Washington sales tax',
108 #};
109
110 =item get_district LOCATION METHOD
111
112 For the location hash in LOCATION, using lookup method METHOD, fetch
113 tax district information.  Currently the only available method is 
114 'wa_sales' (the Washington Department of Revenue sales tax lookup).
115
116 Returns a hash reference containing the following fields:
117
118 - district
119 - tax (percentage)
120 - taxname
121 - exempt_amount (currently zero)
122 - city, county, state, country (from 
123
124 The intent is that you can assign this to an L<FS::cust_main_county> 
125 object and insert it if there's not yet a tax rate defined for that 
126 district.
127
128 get_district will die on error.
129
130 =over 4
131
132 =cut
133
134 sub get_district {
135   no strict 'refs';
136   my $location = shift;
137   my $method = shift or return '';
138   warn Dumper($location, $method) if $DEBUG;
139   &$method($location);
140 }
141
142 sub wa_sales {
143   my $location = shift;
144   my $error = '';
145   return '' if $location->{state} ne 'WA';
146
147   my $return = { %$location };
148   $return->{'exempt_amount'} = 0.00;
149
150   my $url = 'http://webgis2.dor.wa.gov/TaxRateLookup_AGS/TaxReport.aspx';
151   my $ua = new LWP::UserAgent;
152
153   my $delim = '<|>'; # yes, <|>
154   my $year  = (localtime)[5] + 1900;
155   my $month = (localtime)[4] + 1;
156   my @zip = split('-', $location->{zip});
157
158   my @args = (
159     'TaxType=S',  #sales; 'P' = property
160     'Src=0',      #does something complicated
161     'TAXABLE=',
162     'Addr='.uri_escape($location->{address1}),
163     'City='.uri_escape($location->{city}),
164     'Zip='.$zip[0],
165     'Zip1='.($zip[1] || ''), #optional
166     'Year='.$year,
167     'SYear='.$year,
168     'Month='.$month,
169     'EMon='.$month,
170   );
171   
172   my $query_string = join($delim, @args );
173   $url .= "?$query_string";
174   warn "\nrequest:  $url\n\n" if $DEBUG > 1;
175
176   my $res = $ua->request( GET( "$url?$query_string" ) );
177
178   warn $res->as_string
179   if $DEBUG > 2;
180
181   if ($res->code ne '200') {
182     $error = $res->message;
183   }
184
185   my $content = $res->content;
186   my $p = new HTML::TokeParser \$content;
187   my $js = '';
188   while ( my $t = $p->get_tag('script') ) {
189     my $u = $p->get_token; #either enclosed text or the </script> tag
190     if ( $u->[0] eq 'T' and $u->[1] =~ /tblSales/ ) {
191       $js = $u->[1];
192       last;
193     }
194   }
195   if ( $js ) { #found it
196     # strip down to the quoted string, which contains escaped single quotes.
197     $js =~ s/.*\('tblSales'\);c.innerHTML='//s;
198     $js =~ s/(?<!\\)'.*//s; # (?<!\\) means "not preceded by a backslash"
199     warn "\n\n  innerHTML:\n$js\n\n" if $DEBUG > 2;
200
201     $p = new HTML::TokeParser \$js;
202     TD: while ( my $td = $p->get_tag('td') ) {
203       while ( my $u = $p->get_token ) {
204         next TD if $u->[0] eq 'E' and $u->[1] eq 'td';
205         next if $u->[0] ne 'T'; # skip non-text
206         my $text = $u->[1];
207
208         if ( lc($text) eq 'location code' ) {
209           $p->get_tag('td'); # skip to the next column
210           undef $u;
211           $u = $p->get_token until $u->[0] eq 'T'; # and then skip non-text
212           $return->{'district'} = $u->[1];
213         }
214         elsif ( lc($text) eq 'total tax rate' ) {
215           $p->get_tag('td');
216           undef $u;
217           $u = $p->get_token until $u->[0] eq 'T';
218           $return->{'tax'} = $u->[1];
219         }
220       } # get_token
221     } # TD
222
223     # just to make sure
224     if ( $return->{'district'} =~ /^\d+$/ and $return->{'tax'} =~ /^.\d+$/ ) {
225       $return->{'tax'} *= 100; #percentage
226       warn Dumper($return) if $DEBUG > 1;
227       return $return;
228     }
229     else {
230       $error = 'district code/tax rate not found';
231     }
232   }
233   else {
234     $error = "failed to parse document";
235   }
236
237   die "WA tax district lookup error: $error";
238 }
239
240 ###### USPS Standardization ######
241
242 sub standardize_usps {
243   my $class = shift;
244
245   eval "use Business::US::USPS::WebTools::AddressStandardization";
246   die $@ if $@;
247
248   my $location = shift;
249   if ( $location->{country} ne 'US' ) {
250     # soft failure
251     warn "standardize_usps not for use in country ".$location->{country}."\n";
252     $location->{addr_clean} = '';
253     return $location;
254   }
255   my $userid   = $conf->config('usps_webtools-userid');
256   my $password = $conf->config('usps_webtools-password');
257   my $verifier = Business::US::USPS::WebTools::AddressStandardization->new( {
258       UserID => $userid,
259       Password => $password,
260       Testing => 0,
261   } ) or die "error starting USPS WebTools\n";
262
263   my($zip5, $zip4) = split('-',$location->{'zip'});
264
265   my %usps_args = (
266     FirmName => $location->{company},
267     Address2 => $location->{address1},
268     Address1 => $location->{address2},
269     City     => $location->{city},
270     State    => $location->{state},
271     Zip5     => $zip5,
272     Zip4     => $zip4,
273   );
274   warn join('', map "$_: $usps_args{$_}\n", keys %usps_args )
275     if $DEBUG > 1;
276
277   my $hash = $verifier->verify_address( %usps_args );
278
279   warn $verifier->response
280     if $DEBUG > 1;
281
282   die "USPS WebTools error: ".$verifier->{error}{description} ."\n"
283     if $verifier->is_error;
284
285   my $zip = $hash->{Zip5};
286   $zip .= '-' . $hash->{Zip4} if $hash->{Zip4} =~ /\d/;
287
288   { company   => $hash->{FirmName},
289     address1  => $hash->{Address2},
290     address2  => $hash->{Address1},
291     city      => $hash->{City},
292     state     => $hash->{State},
293     zip       => $zip,
294     country   => 'US',
295     addr_clean=> 'Y' }
296 }
297
298 ###### U.S. Census Bureau ######
299
300 sub standardize_uscensus {
301   my $self = shift;
302   my $location = shift;
303
304   eval "use Geo::USCensus::Geocoding";
305   die $@ if $@;
306
307   if ( $location->{country} ne 'US' ) {
308     # soft failure
309     warn "standardize_uscensus not for use in country ".$location->{country}."\n";
310     $location->{addr_clean} = '';
311     return $location;
312   }
313
314   my $request = {
315     street  => $location->{address1},
316     city    => $location->{city},
317     state   => $location->{state},
318     zip     => $location->{zip},
319     debug   => ($DEBUG || 0),
320   };
321
322   my $result = Geo::USCensus::Geocoding->query($request);
323   if ( $result->is_match ) {
324     # unfortunately we get the address back as a single line
325     if ($result->address =~ /^(.*), (.*), ([A-Z]{2}), (\d{5}.*)$/) {
326       return +{
327         address1    => $1,
328         city        => $2,
329         state       => $3,
330         zip         => $4,
331         address2    => uc($location->{address2}),
332         latitude    => $result->latitude,
333         longitude   => $result->longitude,
334         censustract => $result->censustract,
335       };
336     } else {
337       die "can't parse address '".$result->address."'";
338     }
339   } else {
340     warn Dumper($result) if $DEBUG;
341     die $result->error_message;
342   }
343 }
344
345 ####### EZLOCATE (obsolete) #######
346
347 sub _tomtom_query { # helper method for the below
348   my %args = @_;
349   my $result = Geo::TomTom::Geocoding->query(%args);
350   die "TomTom geocoding error: ".$result->message."\n"
351     unless ( $result->is_success );
352   my ($match) = $result->locations;
353   my $type = $match->{type};
354   # match levels below "intersection" should not be considered clean
355   my $clean = ($type eq 'addresspoint'  ||
356                $type eq 'poi'           ||
357                $type eq 'house'         ||
358                $type eq 'intersection'
359               ) ? 'Y' : '';
360   warn "tomtom returned $type match\n" if $DEBUG;
361   warn Dumper($match) if $DEBUG > 1;
362   ($match, $clean);
363 }
364
365 sub standardize_tomtom {
366   # post-2013 TomTom API
367   # much better, but incompatible with ezlocate
368   my $self = shift;
369   my $location = shift;
370   eval "use Geo::TomTom::Geocoding; use Geo::StreetAddress::US";
371   die $@ if $@;
372
373   my $key = $conf->config('tomtom-userid')
374     or die "no tomtom-userid configured\n";
375
376   my $country = code2country($location->{country});
377   my ($address1, $address2) = ($location->{address1}, $location->{address2});
378   my $subloc = '';
379
380   # trim whitespace
381   $address1 =~ s/^\s+//;
382   $address1 =~ s/\s+$//;
383   $address2 =~ s/^\s+//;
384   $address2 =~ s/\s+$//;
385
386   # try to fix some cases of the address fields being switched
387   if ( $address2 =~ /^\d/ and $address1 !~ /^\d/ ) {
388     $address2 = $address1;
389     $address1 = $location->{address2};
390   }
391   # parse sublocation part (unit/suite/apartment...) and clean up 
392   # non-sublocation address2
393   ($subloc, $address2) =
394     subloc_address2($address1, $address2, $location->{country});
395   # ask TomTom to standardize address1:
396   my %args = (
397     key => $key,
398     T   => $address1,
399     L   => $location->{city},
400     AA  => $location->{state},
401     PC  => $location->{zip},
402     CC  => country2code($country, LOCALE_CODE_ALPHA_3),
403   );
404
405   my ($match, $clean) = _tomtom_query(%args);
406
407   if (!$match or !$clean) {
408     # Then try cleaning up the input; TomTom is picky about junk in the 
409     # address.  Any of these can still be a clean match.
410     my $h = Geo::StreetAddress::US->parse_location($address1);
411     # First conservatively:
412     if ( $h->{sec_unit_type} ) {
413       my $strip = '\s+' . $h->{sec_unit_type};
414       $strip .= '\s*' . $h->{sec_unit_num} if $h->{sec_unit_num};
415       $strip .= '$';
416       $args{T} =~ s/$strip//;
417       ($match, $clean) = _tomtom_query(%args);
418     }
419     if ( !$match or !$clean ) {
420       # Then more aggressively:
421       $args{T} = uc( join(' ', @$h{'number', 'street', 'type'}) );
422       ($match, $clean) = _tomtom_query(%args);
423     }
424   }
425
426   if ( !$match or !$clean ) { # partial matches are not useful
427     die "Address not found\n";
428   }
429   my $tract = '';
430   if ( defined $match->{censusTract} ) {
431     $tract = $match->{censusStateCode}. $match->{censusFipsCountyCode}.
432              join('.', $match->{censusTract} =~ /(....)(..)/);
433   }
434   $address1 = '';
435   $address1 = $match->{houseNumber} . ' ' if length($match->{houseNumber});
436   $address1 .= $match->{street} if $match->{street};
437   $address1 .= ' '.$subloc if $subloc;
438   $address1 = uc($address1); # USPS standards
439
440   return +{
441     address1    => $address1,
442     address2    => $address2,
443     city        => uc($match->{city}),
444     state       => uc($location->{state}),
445     country     => uc($location->{country}),
446     zip         => ($match->{standardPostalCode} || $match->{postcode}),
447     latitude    => $match->{latitude},
448     longitude   => $match->{longitude},
449     censustract => $tract,
450     addr_clean  => $clean,
451   };
452 }
453
454 =iten subloc_address2 ADDRESS1, ADDRESS2, COUNTRY
455
456 Given 'address1' and 'address2' strings, extract the sublocation part 
457 (from either one) and return it.  If the sublocation was found in ADDRESS1,
458 also return ADDRESS2 (cleaned up for postal standards) as it's assumed to
459 contain something relevant.
460
461 =cut
462
463 my %subloc_forms = (
464   # Postal Addressing Standards, Appendix C
465   # (plus correction of "hanger" to "hangar")
466   US => {qw(
467     APARTMENT     APT
468     BASEMENT      BSMT
469     BUILDING      BLDG
470     DEPARTMENT    DEPT
471     FLOOR         FL
472     FRONT         FRNT
473     HANGAR        HNGR
474     HANGER        HNGR
475     KEY           KEY
476     LOBBY         LBBY
477     LOT           LOT
478     LOWER         LOWR
479     OFFICE        OFC
480     PENTHOUSE     PH
481     PIER          PIER
482     REAR          REAR
483     ROOM          RM
484     SIDE          SIDE
485     SLIP          SLIP
486     SPACE         SPC
487     STOP          STOP
488     SUITE         STE
489     TRAILER       TRLR
490     UNIT          UNIT
491     UPPER         UPPR
492   )},
493   # Canada Post Addressing Guidelines 4.3
494   CA => {qw(
495     APARTMENT     APT
496     APPARTEMENT   APP
497     BUREAU        BUREAU
498     SUITE         SUITE
499     UNIT          UNIT
500     UNITÉ         UNITÉ
501   )},
502 );
503  
504 sub subloc_address2 {
505   # Some things seen in the address2 field:
506   # Whitespace
507   # The complete address (with address1 containing part of the company name, 
508   # or an ATTN or DBA line, or P.O. Box, or department name, or building/suite
509   # number, etc.)
510
511   # try to parse sublocation parts from address1; if they are present we'll
512   # append them back to address1 after standardizing
513   my $subloc = '';
514   my ($addr1, $addr2, $country) = map uc, @_;
515   my $dict = $subloc_forms{$country} or return('', $addr2);
516   
517   my $found_in = 0; # which address is the sublocation
518   my $h;
519   foreach my $string (
520     # patterns to try to parse
521     $addr1,
522     "$addr1 Nullcity, CA"
523   ) {
524     $h = Geo::StreetAddress::US->parse_location($addr1);
525     last if exists($h->{sec_unit_type});
526   }
527   if (exists($h->{sec_unit_type})) {
528     $found_in = 1
529   } else {
530     foreach my $string (
531       # more patterns
532       $addr2,
533       "$addr1, $addr2",
534       "$addr1, $addr2 Nullcity, CA"
535     ) {
536       $h = Geo::StreetAddress::US->parse_location("$addr1, $addr2");
537       last if exists($h->{sec_unit_type});
538     }
539     if (exists($h->{sec_unit_type})) {
540       $found_in = 2;
541     }
542   }
543   if ( $found_in ) {
544     $subloc = $h->{sec_unit_type};
545     # special case: do not combine P.O. box sublocs with address1
546     if ( $h->{sec_unit_type} =~ /^P *O *BOX/i ) {
547       if ( $found_in == 2 ) {
548         $addr2 = "PO BOX ".$h->{sec_unit_num};
549       } # else it's in addr1, and leave it alone
550       return ('', $addr2);
551     } elsif ( exists($dict->{$subloc}) ) {
552       # substitute the official abbreviation
553       $subloc = $dict->{$subloc};
554     }
555     $subloc .= ' ' . $h->{sec_unit_num} if length($h->{sec_unit_num});
556   } # otherwise $subloc = ''
557
558   if ( $found_in == 2 ) {
559     # address2 should be fully combined into address1
560     return ($subloc, '');
561   }
562   # else address2 is not the canonical sublocation, but do our best to 
563   # clean it up
564   #
565   # protect this
566   $addr2 =~ s/#\s*(\d)/NUMBER$1/; # /g?
567   my @words;
568   # remove all punctuation and spaces
569   foreach my $w (split(/\W+/, $addr2)) {
570     if ( exists($dict->{$w}) ) {
571       push @words, $dict->{$w};
572     } else {
573       push @words, $w;
574     }
575     my $result = join(' ', @words);
576     # correct spacing of pound sign + number
577     $result =~ s/NUMBER(\d)/# $1/;
578     warn "normalizing '$addr2' to '$result'\n" if $DEBUG > 1;
579     $addr2 = $result;
580   }
581   $addr2 = '' if $addr2 eq $subloc; # if it was entered redundantly
582   ($subloc, $addr2);
583 }
584
585 sub standardize_melissa {
586   my $class = shift;
587   my $location = shift;
588
589   local $@;
590   eval "use Geo::Melissa::WebSmart";
591   die $@ if $@;
592
593   my $id = $conf->config('melissa-userid')
594     or die "no melissa-userid configured\n";
595   my $geocode = $conf->exists('melissa-enable_geocoding') ? 1 : 0;
596
597   my $request = {
598     id      => $id,
599     a1      => $location->{address1},
600     a2      => $location->{address2},
601     city    => $location->{city},
602     state   => $location->{state},
603     ctry    => $location->{country},
604     zip     => $location->{zip},
605     geocode => $geocode,
606   };
607   my $result = Geo::Melissa::WebSmart->query($request);
608   if ( $result->code =~ /AS01/ ) { # always present on success
609     my $addr = $result->address;
610     warn Dumper $addr if $DEBUG > 1;
611     my $out = {
612       address1    => $addr->{Address1},
613       address2    => $addr->{Address2},
614       city        => $addr->{City}->{Name},
615       state       => $addr->{State}->{Abbreviation},
616       country     => $addr->{Country}->{Abbreviation},
617       zip         => $addr->{Zip},
618       latitude    => $addr->{Latitude},
619       longitude   => $addr->{Longitude},
620       addr_clean  => 'Y',
621     };
622     if ( $addr->{Census}->{Tract} ) {
623       my $censustract = $addr->{County}->{Fips} . $addr->{Census}->{Tract};
624       # insert decimal point two digits from the end
625       $censustract =~ s/(\d\d)$/\.$1/;
626       $out->{censustract} = $censustract;
627       $out->{censusyear} = $conf->config('census_year');
628     }
629     # we could do a lot more nuanced reporting of the warning/status codes,
630     # but the UI doesn't support that yet.
631     return $out;
632   } else {
633     die $result->status_message;
634   }
635 }
636
637 =back
638
639 =cut
640
641 1;