sales tax districts, #15089
authormark <mark>
Fri, 13 Jan 2012 10:13:56 +0000 (10:13 +0000)
committermark <mark>
Fri, 13 Jan 2012 10:13:56 +0000 (10:13 +0000)
14 files changed:
FS/FS/Conf.pm
FS/FS/Mason.pm
FS/FS/Misc/Geo.pm
FS/FS/Schema.pm
FS/FS/cust_location.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main_county.pm
FS/FS/geocode_Mixin.pm
FS/bin/freeside-tax-district-update [new file with mode: 0644]
httemplate/browse/cust_main_county.cgi
httemplate/edit/cust_main_county.html
httemplate/elements/location.html
httemplate/view/cust_main/misc.html

index 3ac5dcd..9c043f6 100644 (file)
@@ -13,6 +13,7 @@ use FS::payby;
 use FS::conf;
 use FS::Record qw(qsearch qsearchs);
 use FS::UID qw(dbh datasrc use_confcompat);
+use FS::Misc::Geo;
 
 $base_dir = '%%%FREESIDE_CONF%%%';
 
@@ -3748,6 +3749,14 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'tax_district_method',
+    'section'     => 'UI',
+    'description' => 'The method to use to look up tax district codes.',
+    'type'        => 'select',
+    'select_hash' => [ FS::Misc::Geo::get_district_methods() ],
+  },
+
+  {
     'key'         => 'company_latitude',
     'section'     => 'UI',
     'description' => 'Your company latitude (-90 through 90)',
index d8e3948..6cc32bd 100644 (file)
@@ -64,7 +64,7 @@ if ( -e $addl_handler_use_file ) {
   use DateTime;
   use DateTime::Format::Strptime;
   use FS::Misc::DateTime qw( parse_datetime );
-  use FS::Misc::Geo qw( get_censustract );
+  use FS::Misc::Geo qw( get_censustract get_district );
   use Lingua::EN::Inflect qw(PL);
   Lingua::EN::Inflect::classical names=>0; #Categorys
   use Tie::IxHash;
index c193f33..acfeabf 100644 (file)
@@ -7,11 +7,12 @@ use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Request::Common qw( GET POST );
 use HTML::TokeParser;
+use URI::Escape;
 use Data::Dumper;
 
 $DEBUG = 0;
 
-@EXPORT_OK = qw( get_censustract );
+@EXPORT_OK = qw( get_censustract get_district );
 
 =head1 NAME
 
@@ -21,7 +22,7 @@ FS::Misc::Geo - routines to fetch geographic information
 
 =over 4
 
-=item censustract LOCATION YEAR
+=item get_censustract LOCATION YEAR
 
 Given a location hash (see L<FS::location_Mixin>) and a census map year,
 returns a census tract code (consisting of state, county, and tract 
@@ -131,4 +132,144 @@ sub get_censustract {
   $return->{'statecode'} .  $return->{'countycode'} .  $return->{'tractcode'};
 }
 
+sub get_district_methods {
+  ''         => '',
+  'wa_sales' => 'Washington sales tax',
+};
+
+=item get_district LOCATION METHOD
+
+For the location hash in LOCATION, using lookup method METHOD, fetch
+tax district information.  Currently the only available method is 
+'wa_sales' (the Washington Department of Revenue sales tax lookup).
+
+Returns a hash reference containing the following fields:
+
+- district
+- tax (percentage)
+- taxname
+- exempt_amount (currently zero)
+- city, county, state, country (from 
+
+The intent is that you can assign this to an L<FS::cust_main_county> 
+object and insert it if there's not yet a tax rate defined for that 
+district.
+
+get_district will die on error.
+
+=over 4
+
+=cut
+
+sub get_district {
+  no strict 'refs';
+  my $location = shift;
+  my $method = shift or return '';
+  warn Dumper($location, $method) if $DEBUG;
+  &$method($location);
+}
+
+sub wa_sales {
+  my $location = shift;
+  my $error = '';
+  return '' if $location->{state} ne 'WA';
+
+  my $return = { %$location };
+  $return->{'exempt_amount'} = 0.00;
+
+  my $url = 'http://webgis2.dor.wa.gov/TaxRateLookup_AGS/TaxReport.aspx';
+  my $ua = new LWP::UserAgent;
+
+  my $delim = '<|>'; # yes, <|>
+  my $year  = (localtime)[5] + 1900;
+  my $month = (localtime)[4] + 1;
+  my @zip = split('-', $location->{zip});
+
+  my @args = (
+    'TaxType=S',  #sales; 'P' = property
+    'Src=0',      #does something complicated
+    'TAXABLE=',
+    'Addr='.uri_escape($location->{address1}),
+    'City='.uri_escape($location->{city}),
+    'Zip='.$zip[0],
+    'Zip1='.($zip[1] || ''), #optional
+    'Year='.$year,
+    'SYear='.$year,
+    'Month='.$month,
+    'EMon='.$month,
+  );
+  
+  my $query_string = join($delim, @args );
+  $url .= "?$query_string";
+  warn "\nrequest:  $url\n\n" if $DEBUG;
+
+  my $res = $ua->request( GET( "$url?$query_string" ) );
+
+  warn $res->as_string
+  if $DEBUG > 1;
+
+  if ($res->code ne '200') {
+    $error = $res->message;
+  }
+
+  my $content = $res->content;
+  my $p = new HTML::TokeParser \$content;
+  my $js = '';
+  while ( my $t = $p->get_tag('script') ) {
+    my $u = $p->get_token; #either enclosed text or the </script> tag
+    if ( $u->[0] eq 'T' and $u->[1] =~ /tblSales/ ) {
+      $js = $u->[1];
+      last;
+    }
+  }
+  if ( $js ) { #found it
+    # strip down to the quoted string, which contains escaped single quotes.
+    $js =~ s/.*\('tblSales'\);c.innerHTML='//s;
+    $js =~ s/(?<!\\)'.*//s; # (?<!\\) means "not preceded by a backslash"
+    warn "\n\n  innerHTML:\n$js\n\n" if $DEBUG > 2;
+
+    $p = new HTML::TokeParser \$js;
+    TD: while ( my $td = $p->get_tag('td') ) {
+      while ( my $u = $p->get_token ) {
+        next TD if $u->[0] eq 'E' and $u->[1] eq 'td';
+        next if $u->[0] ne 'T'; # skip non-text
+        my $text = $u->[1];
+
+        if ( lc($text) eq 'location code' ) {
+          $p->get_tag('td'); # skip to the next column
+          undef $u;
+          $u = $p->get_token until $u->[0] eq 'T'; # and then skip non-text
+          $return->{'district'} = $u->[1];
+        }
+        elsif ( lc($text) eq 'total tax rate' ) {
+          $p->get_tag('td');
+          undef $u;
+          $u = $p->get_token until $u->[0] eq 'T';
+          $return->{'tax'} = $u->[1];
+        }
+      } # get_token
+    } # TD
+
+    # just to make sure
+    if ( $return->{'district'} =~ /^\d+$/ and $return->{'tax'} =~ /^.\d+$/ ) {
+      $return->{'tax'} *= 100; #percentage
+      warn Dumper($return) if $DEBUG;
+      return $return;
+    }
+    else {
+      $error = 'district code/tax rate not found';
+    }
+  }
+  else {
+    $error = "failed to parse document";
+  }
+
+  die "WA tax district lookup error: $error";
+}
+
+=back
+
+=cut
+
+
 1;
index d270dba..d8e0f5c 100644 (file)
@@ -896,6 +896,7 @@ sub tables_hashref {
         'geocode',  'varchar', 'NULL', 20,  '', '',
         'censustract', 'varchar', 'NULL', 20,  '', '', # 7 to save space?
         'censusyear', 'char', 'NULL', 4, '', '',
+        'district', 'varchar', 'NULL', 20, '', '',
         'tax',      'char', 'NULL', 1, '', '', 
         'otaker',   'varchar', 'NULL',    32, '', '', 
         'usernum',   'int', 'NULL', '', '', '',
@@ -1054,6 +1055,7 @@ sub tables_hashref {
         'coord_auto',         'char', 'NULL',       1, '', '',
         'country',            'char',     '',       2, '', '', 
         'geocode',         'varchar', 'NULL',      20, '', '',
+        'district',        'varchar', 'NULL',      20, '', '',
         'location_type',   'varchar', 'NULL',      20, '', '',
         'location_number', 'varchar', 'NULL',      20, '', '',
         'location_kind',      'char', 'NULL',       1, '', '',
@@ -1178,11 +1180,12 @@ sub tables_hashref {
       'index'       => [ [ 'custnum' ], [ 'billpkgnum' ] ],
     },
 
-    'cust_main_county' => { #county+state+country are checked off the
-                            #cust_main_county for validation and to provide
-                            # a tax rate.
+    'cust_main_county' => { #district+city+county+state+country are checked 
+                            #off the cust_main_county for validation and to 
+                            #provide a tax rate.
       'columns' => [
         'taxnum',   'serial',   '',    '', '', '', 
+        'district', 'varchar',  'NULL',    20, '', '',
         'city',     'varchar',  'NULL',    $char_d, '', '',
         'county',   'varchar',  'NULL',    $char_d, '', '', 
         'state',    'varchar',  'NULL',    $char_d, '', '', 
@@ -1197,7 +1200,8 @@ sub tables_hashref {
       'primary_key' => 'taxnum',
       'unique' => [],
   #    'unique' => [ ['taxnum'], ['state', 'county'] ],
-      'index' => [ [ 'city' ], [ 'county' ], [ 'state' ], [ 'country' ],
+      'index' => [ [ 'district' ], [ 'city' ], [ 'county' ], [ 'state' ], 
+                   [ 'country' ],
                    [ 'taxclass' ],
                  ],
     },
index a3d5bcb..f863b10 100644 (file)
@@ -76,6 +76,10 @@ Country (see L<FS::cust_main_county>)
 
 Geocode
 
+=item district
+
+Tax district code (optional)
+
 =item disabled
 
 Disabled flag; set to 'Y' to disable the location.
@@ -102,6 +106,26 @@ sub table { 'cust_location'; }
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+=cut
+
+sub insert {
+  my $self = shift;
+  my $error = $self->SUPER::insert(@_);
+
+  #false laziness with cust_main, will go away eventually
+  my $conf = new FS::Conf;
+  if ( !$error and $conf->config('tax_district_method') ) {
+
+    my $queue = new FS::queue {
+      'job' => 'FS::geocode_Mixin::process_district_update'
+    };
+    $error = $queue->insert( ref($self), $self->locationnum );
+
+  }
+
+  $error || '';
+}
+
 =item delete
 
 Delete this record from the database.
@@ -111,6 +135,30 @@ Delete this record from the database.
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
+=cut
+
+sub replace {
+  my $self = shift;
+  my $old = shift;
+  $old ||= $self->replace_old;
+  my $error = $self->SUPER::replace($old);
+
+  #false laziness with cust_main, will go away eventually
+  my $conf = new FS::Conf;
+  if ( !$error and $conf->config('tax_district_method') 
+    and $self->get('address1') ne $old->get('address1') ) {
+
+    my $queue = new FS::queue {
+      'job' => 'FS::geocode_Mixin::process_district_update'
+    };
+    $error = $queue->insert( ref($self), $self->locationnum );
+
+  }
+
+  $error || '';
+}
+
+
 =item check
 
 Checks all fields to make sure this is a valid location.  If there is
@@ -142,6 +190,7 @@ sub check {
     || $self->ut_textn('location_number')
     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
     || $self->ut_alphan('geocode')
+    || $self->ut_alphan('district')
   ;
   return $error if $error;
 
index 687e311..53ecf5b 100644 (file)
@@ -609,6 +609,20 @@ sub insert {
     }
   }
 
+  # FS::geocode_Mixin::after_insert or something?
+  if ( $conf->config('tax_district_method') and !$import ) {
+    # if anything non-empty, try to look it up
+    my $queue = new FS::queue {
+      'job'     => 'FS::geocode_Mixin::process_district_update',
+      'custnum' => $self->custnum,
+    };
+    my $error = $queue->insert( ref($self), $self->custnum );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "queueing tax district update: $error";
+    }
+  }
+
   # cust_main exports!
   warn "  exporting\n" if $DEBUG > 1;
 
@@ -1633,6 +1647,25 @@ sub replace {
     }
   }
 
+  # FS::geocode_Mixin::after_replace ?
+  # though this will go away anyway once we move customer bill/service 
+  # locations into cust_location
+  # We can trigger this on any address change--just have to make sure 
+  # not to trigger it on itself.
+  if ( $conf->config('tax_district_method') and !$import 
+      and ( $self->get('ship_address1') ne $old->get('ship_address1')
+        or  $self->get('address1')      ne $old->get('address1') ) ) {
+    my $queue = new FS::queue {
+      'job'     => 'FS::geocode_Mixin::process_district_update',
+      'custnum' => $self->custnum,
+    };
+    my $error = $queue->insert( ref($self), $self->custnum );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "queueing tax district update: $error";
+    }
+  }
+
   # cust_main exports!
 
   my $export_args = $options{'export_args'} || [];
@@ -1739,6 +1772,7 @@ sub check {
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
     || $self->ut_alphan('geocode')
+    || $self->ut_alphan('district')
     || $self->ut_floatn('cdr_termination_percentage')
     || $self->ut_floatn('credit_limit')
     || $self->ut_numbern('billday')
@@ -2143,8 +2177,9 @@ sub has_ship_address {
 
 =item location_hash
 
-Returns a list of key/value pairs, with the following keys: address1, adddress2,
-city, county, state, zip, country, and geocode.  The shipping address is used if present.
+Returns a list of key/value pairs, with the following keys: address1, 
+adddress2, city, county, state, zip, country, district, and geocode.  The 
+shipping address is used if present.
 
 =cut
 
index 37053fe..072874e 100644 (file)
@@ -1165,7 +1165,7 @@ sub _handle_taxes {
 
     } else {
 
-      my @loc_keys = qw( city county state country );
+      my @loc_keys = qw( district city county state country );
       my %taxhash;
       if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
         my $cust_location = $cust_pkg->cust_location;
@@ -1182,7 +1182,7 @@ sub _handle_taxes {
 
       my @taxes = ();
       my %taxhash_elim = %taxhash;
-      my @elim = qw( city county state );
+      my @elim = qw( district city county state );
       do { 
 
         #first try a match with taxclass
index effc428..e937b20 100644 (file)
@@ -56,6 +56,8 @@ currently supported:
 
 =item taxnum - primary key (assigned automatically for new tax rates)
 
+=item district - tax district (optional)
+
 =item city
 
 =item county
@@ -119,6 +121,7 @@ sub check {
   $self->exempt_amount(0) unless $self->exempt_amount;
 
   $self->ut_numbern('taxnum')
+    || $self->ut_alphan('district')
     || $self->ut_textn('city')
     || $self->ut_textn('county')
     || $self->ut_anything('state')
index d821c80..29491db 100644 (file)
@@ -132,7 +132,13 @@ sub location_label {
   $line;
 }
 
-=item set_coord
+=item set_coord [ PREFIX ]
+
+Look up the coordinates of the location using (currently) the Google Maps
+API and set the 'latitude' and 'longitude' fields accordingly.
+
+PREFIX, if specified, will be prepended to all location field names,
+including latitude and longitude.
 
 =cut
 
@@ -213,6 +219,54 @@ sub geocode {
   $geocode;
 }
 
+=item process_district_update CLASS ID
+
+Queueable function to update the tax district code using the selected method 
+(config 'tax_district_method').  CLASS is either 'FS::cust_main' or 
+'FS::cust_location'; ID is the key in one of those tables.
+
+=cut
+
+sub process_district_update {
+  my $class = shift;
+  my $id = shift;
+
+  eval "use FS::Misc::Geo qw(get_district); use FS::Conf; use $class;";
+  die $@ if $@;
+  die "$class has no location data" if !$class->can('location_hash');
+
+  my $conf = FS::Conf->new;
+  my $method = $conf->config('tax_district_method')
+    or return; #nothing to do if null
+  my $self = $class->by_key($id) or die "object $id not found";
+
+  # dies on error, fine
+  my $tax_info = get_district({ $self->location_hash }, $method);
+  
+  if ( $tax_info ) {
+    $self->set('district', $tax_info->{'district'} );
+    my $error = $self->replace;
+    die $error if $error;
+
+    my %hash = map { $_ => $tax_info->{$_} } 
+      qw( district city county state country );
+    my $old = qsearchs('cust_main_county', \%hash);
+    if ( $old ) {
+      my $new = new FS::cust_main_county { $old->hash, %$tax_info };
+      warn "updating tax rate for district ".$tax_info->{'district'} if $DEBUG;
+      $error = $new->replace($old);
+    }
+    else {
+      my $new = new FS::cust_main_county $tax_info;
+      warn "creating tax rate for district ".$tax_info->{'district'} if $DEBUG;
+      $error = $new->insert;
+    }
+    die $error if $error;
+
+  }
+  return;
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/bin/freeside-tax-district-update b/FS/bin/freeside-tax-district-update
new file mode 100644 (file)
index 0000000..e32bc9d
--- /dev/null
@@ -0,0 +1,78 @@
+#!/usr/bin/perl
+
+use strict;
+use Getopt::Std;
+use Date::Parse 'str2time';
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch dbh);
+use FS::Conf;
+use FS::cust_main;
+use FS::h_cust_main;
+
+my %opt;
+getopts('n', \%opt);
+
+my $user = shift or die &usage;
+adminsuidsetup($user);
+$FS::UID::AutoCommit = 0;
+my $dbh = dbh;
+
+my $conf = FS::Conf->new;
+my $method = $conf->config('tax_district_method')
+  or die "no tax district lookup method configured.\n";
+
+my %limit;
+%limit = ( district => '' ) if $opt{'n'};
+my @location = qsearch( 'cust_main', \%limit ),
+               qsearch( 'cust_location', { disabled => '', %limit } );
+
+# breaking the rules somewhat by modifying cust_location records in place 
+# instead of doing a proper package change, but we're not changing the 
+# actual address
+warn scalar(@location)." records found.\n";
+my $queued = 0; my $updated = 0;
+foreach my $location (@location) {
+  my $error;
+  my $job = FS::queue->new({
+      job => 'FS::geocode_Mixin::process_district_update'
+    });
+  my $class = ref($location);
+  my $id = $class eq 'FS::cust_main' ? 
+                                  $location->custnum : 
+                                  $location->locationnum;
+  $error = $job->insert($class, $id);
+  if ( $error ) {
+    $dbh->rollback;
+    die "error queueing update for $class $id\n";
+  }
+  $queued++;
+}
+warn "Queued $queued tax district lookups.\n";
+$dbh->commit;
+
+sub usage {
+  "Usage:\n\n  freeside-tax-district-update [ -n ] user\n\n"
+}
+
+=head1 NAME
+
+freeside-tax-district-update - Update tax district codes from a lookup source.
+
+=head1 SYNOPSIS
+
+  freeside-tax-district-update [ -n ] user
+
+=head1 DESCRIPTION
+
+Updates the 'district' field for all customers and service locations 
+using an online tax information lookup method.  Currently the only 
+one supported is the Washington Department of Revenue sales tax table, 
+and looking up the tax district will create a cust_main_county record
+with the tax rate for that district.
+
+The -n option tells the script to ignore customers and locations that 
+already have a district code.
+
+The actual lookup operation will run from the job queue.
+
+=cut
index c6484ca..dfbe119 100755 (executable)
@@ -10,7 +10,7 @@
                            'table'    => 'cust_main_county',
                            'hashref'  => $hashref,
                            'order_by' =>
-                             'ORDER BY country, state, county, city, taxclass',
+                  'ORDER BY country, state, county, city, district, taxclass',
                          },
      'count_query'    => $count_query,
      'header'         => \@header,
@@ -440,11 +440,12 @@ if ( $taxclass ) {
 
 $cell_style = '';
 
-my @header        = ( 'Country', 'State/Province', 'County', 'City' );
-my @header2       = ( '', '', '', '', );
-my @links         = ( '', '', '', '', );
-my @link_onclicks = ( '', '', '', '', );
-my $align = 'llll';
+my @header        = ( 'Country', 'State/Province', 'County', 'City', '' );
+# last column is 'district', but usually unused
+my @header2       = ( '', '', '', '', '' );
+my @links         = ( '', '', '', '', '' );
+my @link_onclicks = ( '', '', '', '', '' );
+my $align = 'lllll';
 
 my %seen_country = ();
 my %seen_state = ();
@@ -532,7 +533,8 @@ my @fields = (
         my $r = shift;
         if ( $r->city ) {
 
-          if ( $r->taxclass ) { #but if it has a taxclass, can't remove
+          if ( $r->taxclass #but if it has a taxclass, can't remove
+              or $r->district ) { # or a district
             $r->city;
           } else {
             $r->city. '&nbsp;'.
@@ -551,6 +553,21 @@ my @fields = (
                        );
         }
       },
+
+  #district
+  sub {
+        my $r = shift;
+        if ( $r->district ) {
+          $r->district . '&nbsp;'.
+            remove_link( col  => 'district',
+                         label=> 'remove&nbsp;district',
+                         row  => $r,
+                         cgi  => $cgi,
+                       );
+        }
+        # manually editing districts is not exactly intended
+      },
+
 );
 
 my @color = (
@@ -607,7 +624,8 @@ my $cb_sub = sub {
   my $cust_main_county = shift;
 
   if ( $cb_oldrow ) {
-    if (    $cb_oldrow->city     ne $cust_main_county->city 
+    if (    $cb_oldrow->district ne $cust_main_county->district
+         || $cb_oldrow->city     ne $cust_main_county->city 
          || $cb_oldrow->county   ne $cust_main_county->county  
          || $cb_oldrow->state    ne $cust_main_county->state  
          || $cb_oldrow->country  ne $cust_main_county->country 
index 11b8e43..9d21cc8 100644 (file)
@@ -7,6 +7,7 @@
                    'state'    => 'State',
                    'county'   => 'County',
                    'city'     => 'City',
+                   'district' => 'District',
                    'taxclass' => 'Tax class',
                    'taxname'  => 'Tax name',
                    'tax'      => 'Tax rate',
@@ -48,6 +49,9 @@ my @fields = (
   { field=>'city',     type=>'fixed', },
 );
 
+push @fields, { field=>'district', type=>'fixed', }
+  if $cust_main_county->district;
+
 push @fields, { field=>'taxclass', type=>'fixed', }
   if $conf->exists('enable_taxclasses');
 
index c5509c1..13797fe 100644 (file)
@@ -209,6 +209,14 @@ Example:
 %   } else {
       <INPUT TYPE="hidden" NAME="censustract" VALUE="<% $opt{censustract} %>">
 %   } 
+%   if ( $conf->config('tax_district_method') or $object->get('district') ) {
+    <TR>
+      <<%$th%> ALIGN="right">Tax district<BR>(automatic)</<%$th%>>
+      <TD>
+        <INPUT TYPE="text" NAME="district" VALUE="<%$object->get('district')%>">
+      </TD>
+    </TR>
+%   }
 % } 
 
 <%init>
index f664ae2..28414ef 100644 (file)
 
 % }
 
+% if ( $cust_main->district ) {
+
+  <TR>
+    <TD ALIGN="right"><% mt('Tax district') |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $cust_main->district %></TD>
+  </TR>
+
+% }
+
+
 % if ( $conf->exists('ticket_system-selfservice_edit_subject') ) {
 
   <TR>