4 use base qw( Exporter );
5 use vars qw( $DEBUG @EXPORT_OK $conf );
8 use HTTP::Request::Common qw( GET POST );
13 FS::UID->install_callback( sub {
19 @EXPORT_OK = qw( get_district );
23 FS::Misc::Geo - routines to fetch geographic information
29 =item get_censustract LOCATION YEAR
31 Given a location hash (see L<FS::location_Mixin>) and a census map year,
32 returns a census tract code (consisting of state, county, and tract
33 codes) or an error message.
37 sub get_censustract_ffiec {
42 warn Dumper($location, $year) if $DEBUG;
44 my $url = 'http://www.ffiec.gov/Geocode/default.aspx';
49 my $ua = new LWP::UserAgent;
50 my $res = $ua->request( GET( $url ) );
55 unless ($res->code eq '200') {
57 $error = $res->message;
61 my $content = $res->content;
62 my $p = new HTML::TokeParser \$content;
65 while (my $token = $p->get_tag('input') ) {
66 if ($token->[1]->{name} eq '__VIEWSTATE') {
67 $viewstate = $token->[1]->{value};
69 if ($token->[1]->{name} eq '__EVENTVALIDATION') {
70 $eventvalidation = $token->[1]->{value};
72 last if $viewstate && $eventvalidation;
75 unless ($viewstate && $eventvalidation ) {
77 $error = "either no __VIEWSTATE or __EVENTVALIDATION found";
81 my($zip5, $zip4) = split('-',$location->{zip});
83 $year ||= '2011'; #2012 per http://transition.fcc.gov/form477/techfaqs.html soon/now?
85 __VIEWSTATE => $viewstate,
86 __EVENTVALIDATION => $eventvalidation,
88 txtAddress => $location->{address1},
89 txtCity => $location->{city},
90 ddlbState => $location->{state},
92 btnSearch => 'Search',
94 warn join("\n", @ffiec_args )
97 push @{ $ua->requests_redirectable }, 'POST';
98 $res = $ua->request( POST( $url, \@ffiec_args ) );
102 unless ($res->code eq '200') {
104 $error = $res->message;
108 my @id = qw( MSACode StateCode CountyCode TractCode );
109 $content = $res->content;
110 warn $res->content if $DEBUG > 2;
111 $p = new HTML::TokeParser \$content;
112 my $prefix = 'UcGeoResult11_lb';
114 sub { my $t=shift; scalar( grep { lc($t) eq lc("$prefix$_")} @id ) };
116 while (my $token = $p->get_tag('span') ) {
117 next unless ( $token->[1]->{id} && &$compare( $token->[1]->{id} ) );
118 $token->[1]->{id} =~ /^$prefix(\w+)$/;
119 $return->{lc($1)} = $p->get_trimmed_text("/span");
122 unless ( $return->{tractcode} ) {
123 warn "$error: $content ". Dumper($return) if $DEBUG;
124 $error = "No census tract found";
126 $return->{tractcode} .= ' '
127 unless $error || $JSON::VERSION >= 2; #broken JSON 1 workaround
129 } #unless ($res->code eq '200')
131 } #unless ($viewstate)
133 } #unless ($res->code eq '200')
135 die "FFIEC Geocoding error: $error\n" if $error;
137 $return->{'statecode'} . $return->{'countycode'} . $return->{'tractcode'};
140 sub get_district_methods {
142 'wa_sales' => 'Washington sales tax',
145 =item get_district LOCATION METHOD
147 For the location hash in LOCATION, using lookup method METHOD, fetch
148 tax district information. Currently the only available method is
149 'wa_sales' (the Washington Department of Revenue sales tax lookup).
151 Returns a hash reference containing the following fields:
156 - exempt_amount (currently zero)
157 - city, county, state, country (from
159 The intent is that you can assign this to an L<FS::cust_main_county>
160 object and insert it if there's not yet a tax rate defined for that
163 get_district will die on error.
171 my $location = shift;
172 my $method = shift or return '';
173 warn Dumper($location, $method) if $DEBUG;
178 my $location = shift;
180 return '' if $location->{state} ne 'WA';
182 my $return = { %$location };
183 $return->{'exempt_amount'} = 0.00;
185 my $url = 'http://webgis2.dor.wa.gov/TaxRateLookup_AGS/TaxReport.aspx';
186 my $ua = new LWP::UserAgent;
188 my $delim = '<|>'; # yes, <|>
189 my $year = (localtime)[5] + 1900;
190 my $month = (localtime)[4] + 1;
191 my @zip = split('-', $location->{zip});
194 'TaxType=S', #sales; 'P' = property
195 'Src=0', #does something complicated
197 'Addr='.uri_escape($location->{address1}),
198 'City='.uri_escape($location->{city}),
200 'Zip1='.($zip[1] || ''), #optional
207 my $query_string = join($delim, @args );
208 $url .= "?$query_string";
209 warn "\nrequest: $url\n\n" if $DEBUG > 1;
211 my $res = $ua->request( GET( "$url?$query_string" ) );
216 if ($res->code ne '200') {
217 $error = $res->message;
220 my $content = $res->content;
221 my $p = new HTML::TokeParser \$content;
223 while ( my $t = $p->get_tag('script') ) {
224 my $u = $p->get_token; #either enclosed text or the </script> tag
225 if ( $u->[0] eq 'T' and $u->[1] =~ /tblSales/ ) {
230 if ( $js ) { #found it
231 # strip down to the quoted string, which contains escaped single quotes.
232 $js =~ s/.*\('tblSales'\);c.innerHTML='//s;
233 $js =~ s/(?<!\\)'.*//s; # (?<!\\) means "not preceded by a backslash"
234 warn "\n\n innerHTML:\n$js\n\n" if $DEBUG > 2;
236 $p = new HTML::TokeParser \$js;
237 TD: while ( my $td = $p->get_tag('td') ) {
238 while ( my $u = $p->get_token ) {
239 next TD if $u->[0] eq 'E' and $u->[1] eq 'td';
240 next if $u->[0] ne 'T'; # skip non-text
243 if ( lc($text) eq 'location code' ) {
244 $p->get_tag('td'); # skip to the next column
246 $u = $p->get_token until $u->[0] eq 'T'; # and then skip non-text
247 $return->{'district'} = $u->[1];
249 elsif ( lc($text) eq 'total tax rate' ) {
252 $u = $p->get_token until $u->[0] eq 'T';
253 $return->{'tax'} = $u->[1];
259 if ( $return->{'district'} =~ /^\d+$/ and $return->{'tax'} =~ /^.\d+$/ ) {
260 $return->{'tax'} *= 100; #percentage
261 warn Dumper($return) if $DEBUG > 1;
265 $error = 'district code/tax rate not found';
269 $error = "failed to parse document";
272 die "WA tax district lookup error: $error";
275 sub standardize_usps {
278 eval "use Business::US::USPS::WebTools::AddressStandardization";
281 my $location = shift;
282 if ( $location->{country} ne 'US' ) {
284 warn "standardize_usps not for use in country ".$location->{country}."\n";
285 $location->{addr_clean} = '';
288 my $userid = $conf->config('usps_webtools-userid');
289 my $password = $conf->config('usps_webtools-password');
290 my $verifier = Business::US::USPS::WebTools::AddressStandardization->new( {
292 Password => $password,
294 } ) or die "error starting USPS WebTools\n";
296 my($zip5, $zip4) = split('-',$location->{'zip'});
299 FirmName => $location->{company},
300 Address2 => $location->{address1},
301 Address1 => $location->{address2},
302 City => $location->{city},
303 State => $location->{state},
307 warn join('', map "$_: $usps_args{$_}\n", keys %usps_args )
310 my $hash = $verifier->verify_address( %usps_args );
312 warn $verifier->response
315 die "USPS WebTools error: ".$verifier->{error}{description} ."\n"
316 if $verifier->is_error;
318 my $zip = $hash->{Zip5};
319 $zip .= '-' . $hash->{Zip4} if $hash->{Zip4} =~ /\d/;
321 { company => $hash->{FirmName},
322 address1 => $hash->{Address2},
323 address2 => $hash->{Address1},
324 city => $hash->{City},
325 state => $hash->{State},
331 my %teleatlas_error = ( # USA_Geo_002 documentation
332 10 => 'State not found',
333 11 => 'City not found',
334 12 => 'Invalid street address',
335 14 => 'Street name not found',
336 15 => 'Address range does not exist',
337 16 => 'Ambiguous address',
338 17 => 'Intersection not found', #unused?
341 sub standardize_teleatlas {
343 my $location = shift;
345 if ( $location->{country} eq 'US' ) {
346 $class = 'USA_Geo_004Tool';
348 elsif ( $location->{country} eq 'CA' ) {
349 $class = 'CAN_Geo_001Tool';
351 else { # shouldn't be a fatal error, just pass through unverified address
352 warn "standardize_teleatlas: address lookup in '".$location->{country}.
357 my $path = $conf->config('teleatlas-path') || '';
358 local @INC = (@INC, $path);
361 die "Loading $class failed:\n$@".
362 "\nMake sure the TeleAtlas Perl SDK is installed correctly.\n";
365 my $userid = $conf->config('teleatlas-userid')
366 or die "no teleatlas-userid configured\n";
367 my $password = $conf->config('teleatlas-password')
368 or die "no teleatlas-password configured\n";
371 my $tool = $class->new($userid, $password);
372 my $match = $tool->findAddress(
373 $location->{address1},
376 $location->{zip}, #12345-6789 format is allowed
378 warn "teleatlas returned match:\n".Dumper($match) if $DEBUG > 1;
379 # error handling - B codes indicate success
380 die $teleatlas_error{$match->{MAT_STAT}}."\n"
381 unless $match->{MAT_STAT} =~ /^B\d$/;
384 address1 => $match->{STD_ADDR},
385 address2 => $location->{address2},
386 city => $match->{STD_CITY},
387 state => $match->{STD_ST},
388 country => $location->{country},
389 zip => $match->{STD_ZIP}.'-'.$match->{STD_P4},
390 latitude => $match->{MAT_LAT},
391 longitude => $match->{MAT_LON},
392 censustract => $match->{FIPS_ST}.$match->{FIPS_CTY}.
393 sprintf('%04.2f',$match->{CEN_TRCT}),