JSON::XS -> Cpanel::JSON::XS
[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 Cpanel::JSON::XS;
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 "Geocoding returned '".$result->address."', which does not seem to be a valid address.\n";
338     }
339   } elsif ( $result->match_level eq 'Tie' ) {
340     die "Geocoding was not able to identify a unique matching address.\n";
341   } elsif ( $result->match_level ) {
342     die "Geocoding did not find a matching address.\n";
343   } else {
344     warn Dumper($result) if $DEBUG;
345     die $result->error_message;
346   }
347 }
348
349 ####### EZLOCATE (obsolete) #######
350
351 sub _tomtom_query { # helper method for the below
352   my %args = @_;
353   my $result = Geo::TomTom::Geocoding->query(%args);
354   die "TomTom geocoding error: ".$result->message."\n"
355     unless ( $result->is_success );
356   my ($match) = $result->locations;
357   my $type = $match->{type};
358   # match levels below "intersection" should not be considered clean
359   my $clean = ($type eq 'addresspoint'  ||
360                $type eq 'poi'           ||
361                $type eq 'house'         ||
362                $type eq 'intersection'
363               ) ? 'Y' : '';
364   warn "tomtom returned $type match\n" if $DEBUG;
365   warn Dumper($match) if $DEBUG > 1;
366   ($match, $clean);
367 }
368
369 sub standardize_tomtom {
370   # post-2013 TomTom API
371   # much better, but incompatible with ezlocate
372   my $self = shift;
373   my $location = shift;
374   eval "use Geo::TomTom::Geocoding; use Geo::StreetAddress::US";
375   die $@ if $@;
376
377   my $key = $conf->config('tomtom-userid')
378     or die "no tomtom-userid configured\n";
379
380   my $country = code2country($location->{country});
381   my ($address1, $address2) = ($location->{address1}, $location->{address2});
382   my $subloc = '';
383
384   # trim whitespace
385   $address1 =~ s/^\s+//;
386   $address1 =~ s/\s+$//;
387   $address2 =~ s/^\s+//;
388   $address2 =~ s/\s+$//;
389
390   # try to fix some cases of the address fields being switched
391   if ( $address2 =~ /^\d/ and $address1 !~ /^\d/ ) {
392     $address2 = $address1;
393     $address1 = $location->{address2};
394   }
395   # parse sublocation part (unit/suite/apartment...) and clean up 
396   # non-sublocation address2
397   ($subloc, $address2) =
398     subloc_address2($address1, $address2, $location->{country});
399   # ask TomTom to standardize address1:
400   my %args = (
401     key => $key,
402     T   => $address1,
403     L   => $location->{city},
404     AA  => $location->{state},
405     PC  => $location->{zip},
406     CC  => country2code($country, LOCALE_CODE_ALPHA_3),
407   );
408
409   my ($match, $clean) = _tomtom_query(%args);
410
411   if (!$match or !$clean) {
412     # Then try cleaning up the input; TomTom is picky about junk in the 
413     # address.  Any of these can still be a clean match.
414     my $h = Geo::StreetAddress::US->parse_location($address1);
415     # First conservatively:
416     if ( $h->{sec_unit_type} ) {
417       my $strip = '\s+' . $h->{sec_unit_type};
418       $strip .= '\s*' . $h->{sec_unit_num} if $h->{sec_unit_num};
419       $strip .= '$';
420       $args{T} =~ s/$strip//;
421       ($match, $clean) = _tomtom_query(%args);
422     }
423     if ( !$match or !$clean ) {
424       # Then more aggressively:
425       $args{T} = uc( join(' ', @$h{'number', 'street', 'type'}) );
426       ($match, $clean) = _tomtom_query(%args);
427     }
428   }
429
430   if ( !$match or !$clean ) { # partial matches are not useful
431     die "Address not found\n";
432   }
433   my $tract = '';
434   if ( defined $match->{censusTract} ) {
435     $tract = $match->{censusStateCode}. $match->{censusFipsCountyCode}.
436              join('.', $match->{censusTract} =~ /(....)(..)/);
437   }
438   $address1 = '';
439   $address1 = $match->{houseNumber} . ' ' if length($match->{houseNumber});
440   $address1 .= $match->{street} if $match->{street};
441   $address1 .= ' '.$subloc if $subloc;
442   $address1 = uc($address1); # USPS standards
443
444   return +{
445     address1    => $address1,
446     address2    => $address2,
447     city        => uc($match->{city}),
448     state       => uc($location->{state}),
449     country     => uc($location->{country}),
450     zip         => ($match->{standardPostalCode} || $match->{postcode}),
451     latitude    => $match->{latitude},
452     longitude   => $match->{longitude},
453     censustract => $tract,
454     addr_clean  => $clean,
455   };
456 }
457
458 =iten subloc_address2 ADDRESS1, ADDRESS2, COUNTRY
459
460 Given 'address1' and 'address2' strings, extract the sublocation part 
461 (from either one) and return it.  If the sublocation was found in ADDRESS1,
462 also return ADDRESS2 (cleaned up for postal standards) as it's assumed to
463 contain something relevant.
464
465 =cut
466
467 my %subloc_forms = (
468   # Postal Addressing Standards, Appendix C
469   # (plus correction of "hanger" to "hangar")
470   US => {qw(
471     APARTMENT     APT
472     BASEMENT      BSMT
473     BUILDING      BLDG
474     DEPARTMENT    DEPT
475     FLOOR         FL
476     FRONT         FRNT
477     HANGAR        HNGR
478     HANGER        HNGR
479     KEY           KEY
480     LOBBY         LBBY
481     LOT           LOT
482     LOWER         LOWR
483     OFFICE        OFC
484     PENTHOUSE     PH
485     PIER          PIER
486     REAR          REAR
487     ROOM          RM
488     SIDE          SIDE
489     SLIP          SLIP
490     SPACE         SPC
491     STOP          STOP
492     SUITE         STE
493     TRAILER       TRLR
494     UNIT          UNIT
495     UPPER         UPPR
496   )},
497   # Canada Post Addressing Guidelines 4.3
498   CA => {qw(
499     APARTMENT     APT
500     APPARTEMENT   APP
501     BUREAU        BUREAU
502     SUITE         SUITE
503     UNIT          UNIT
504     UNITÉ         UNITÉ
505   )},
506 );
507  
508 sub subloc_address2 {
509   # Some things seen in the address2 field:
510   # Whitespace
511   # The complete address (with address1 containing part of the company name, 
512   # or an ATTN or DBA line, or P.O. Box, or department name, or building/suite
513   # number, etc.)
514
515   # try to parse sublocation parts from address1; if they are present we'll
516   # append them back to address1 after standardizing
517   my $subloc = '';
518   my ($addr1, $addr2, $country) = map uc, @_;
519   my $dict = $subloc_forms{$country} or return('', $addr2);
520   
521   my $found_in = 0; # which address is the sublocation
522   my $h;
523   foreach my $string (
524     # patterns to try to parse
525     $addr1,
526     "$addr1 Nullcity, CA"
527   ) {
528     $h = Geo::StreetAddress::US->parse_location($addr1);
529     last if exists($h->{sec_unit_type});
530   }
531   if (exists($h->{sec_unit_type})) {
532     $found_in = 1
533   } else {
534     foreach my $string (
535       # more patterns
536       $addr2,
537       "$addr1, $addr2",
538       "$addr1, $addr2 Nullcity, CA"
539     ) {
540       $h = Geo::StreetAddress::US->parse_location("$addr1, $addr2");
541       last if exists($h->{sec_unit_type});
542     }
543     if (exists($h->{sec_unit_type})) {
544       $found_in = 2;
545     }
546   }
547   if ( $found_in ) {
548     $subloc = $h->{sec_unit_type};
549     # special case: do not combine P.O. box sublocs with address1
550     if ( $h->{sec_unit_type} =~ /^P *O *BOX/i ) {
551       if ( $found_in == 2 ) {
552         $addr2 = "PO BOX ".$h->{sec_unit_num};
553       } # else it's in addr1, and leave it alone
554       return ('', $addr2);
555     } elsif ( exists($dict->{$subloc}) ) {
556       # substitute the official abbreviation
557       $subloc = $dict->{$subloc};
558     }
559     $subloc .= ' ' . $h->{sec_unit_num} if length($h->{sec_unit_num});
560   } # otherwise $subloc = ''
561
562   if ( $found_in == 2 ) {
563     # address2 should be fully combined into address1
564     return ($subloc, '');
565   }
566   # else address2 is not the canonical sublocation, but do our best to 
567   # clean it up
568   #
569   # protect this
570   $addr2 =~ s/#\s*(\d)/NUMBER$1/; # /g?
571   my @words;
572   # remove all punctuation and spaces
573   foreach my $w (split(/\W+/, $addr2)) {
574     if ( exists($dict->{$w}) ) {
575       push @words, $dict->{$w};
576     } else {
577       push @words, $w;
578     }
579     my $result = join(' ', @words);
580     # correct spacing of pound sign + number
581     $result =~ s/NUMBER(\d)/# $1/;
582     warn "normalizing '$addr2' to '$result'\n" if $DEBUG > 1;
583     $addr2 = $result;
584   }
585   $addr2 = '' if $addr2 eq $subloc; # if it was entered redundantly
586   ($subloc, $addr2);
587 }
588
589 sub standardize_melissa {
590   my $class = shift;
591   my $location = shift;
592
593   local $@;
594   eval "use Geo::Melissa::WebSmart";
595   die $@ if $@;
596
597   my $id = $conf->config('melissa-userid')
598     or die "no melissa-userid configured\n";
599   my $geocode = $conf->exists('melissa-enable_geocoding') ? 1 : 0;
600
601   my $request = {
602     id      => $id,
603     a1      => $location->{address1},
604     a2      => $location->{address2},
605     city    => $location->{city},
606     state   => $location->{state},
607     ctry    => $location->{country},
608     zip     => $location->{zip},
609     geocode => $geocode,
610   };
611   my $result = Geo::Melissa::WebSmart->query($request);
612   if ( $result->code =~ /AS01/ ) { # always present on success
613     my $addr = $result->address;
614     warn Dumper $addr if $DEBUG > 1;
615     my $out = {
616       address1    => $addr->{Address1},
617       address2    => $addr->{Address2},
618       city        => $addr->{City}->{Name},
619       state       => $addr->{State}->{Abbreviation},
620       country     => $addr->{Country}->{Abbreviation},
621       zip         => $addr->{Zip},
622       latitude    => $addr->{Latitude},
623       longitude   => $addr->{Longitude},
624       addr_clean  => 'Y',
625     };
626     if ( $addr->{Census}->{Tract} ) {
627       my $censustract = $addr->{County}->{Fips} . $addr->{Census}->{Tract};
628       # insert decimal point two digits from the end
629       $censustract =~ s/(\d\d)$/\.$1/;
630       $out->{censustract} = $censustract;
631       $out->{censusyear} = $conf->config('census_year');
632     }
633     # we could do a lot more nuanced reporting of the warning/status codes,
634     # but the UI doesn't support that yet.
635     return $out;
636   } else {
637     die $result->status_message;
638   }
639 }
640
641 =back
642
643 =cut
644
645 1;