9 Tool to update city/district sales tax rates in I<cust_main_county> from
10 the Washington State Department of Revenue website.
12 Creates, or updates, a L<FS::cust_main_county> row for every tax district
13 in Washington state. Some cities have different tax rates based on the
14 address, within the city. Because of this, some cities may have multiple
17 When a Washington state address is inserted or changed in L<FS::cust_location>,
18 a job is queued for FS::geocode_Mixin::process_district_update, to ask the
19 Washington state API which tax district to use for this address.
23 -f <filename>: Skip downloading, and process the given excel file
25 -c <taxclass>: Updated or create records within given tax class,
26 If not specified, taxclass will be set as NULL
28 -t <taxname>: Updated or created records will be set to the given tax name.
29 If not specified, conf value 'tax_district_taxname' will be used
31 -y <year>: Specify year for tax table - defaults to current year
33 -q <quarter>: Specify quarter for tax table - defaults to current quarter
35 =head1 Washington State Department of Revenue Resources
37 The state of Washington makes data files available via their public website.
38 It's possible the availability or format of these files may change. As of now,
39 the only data file that contains both city and county names is published in
42 =item WA Dept of Revenue
46 =item Data file downloads
48 https://dor.wa.gov/find-taxes-rates/sales-and-use-tax-rates/downloadable-database
50 =item XLSX file example
52 https://dor.wa.gov/sites/default/files/legacy/Docs/forms/ExcsTx/LocSalUseTx/ExcelLocalSlsUserates_19_Q1.xlsx
54 =item CSV file example
56 https://dor.wa.gov/sites/default/files/legacy/downloads/Add_DataRates2018Q4.zip
59 =item Address lookup API tool
61 http://webgis.dor.wa.gov/webapi/AddressRates.aspx?output=xml&addr=410 Terry Ave. North&city=&zip=98100
68 our $VERSION = '0.02'; # Make Getopt:Std happy
72 use File::Temp 'tempdir';
73 use File::Slurp qw(read_file write_file);
76 use Spreadsheet::XLSX;
78 use FS::cust_main_county;
80 use FS::Record qw(qsearch qsearchs dbh);
81 use FS::UID qw(adminsuidsetup);
84 getopts( 'c:t:y:q:f:', \%opts );
86 or die HELP_MESSAGE();
88 adminsuidsetup( $user )
89 or die "bad username '$user'\n";
91 my $temp_dir = tempdir();
92 my $log = FS::Log->new('wa_tax_rate_update');
94 $opts{t} ||= FS::Conf->new->config('tax_district_taxname');
96 log_info_and_warn( "Set taxclass=$opts{c}" ) if $opts{c};
97 log_info_and_warn( "Set taxname=$opts{t}" ) if $opts{t};
99 my $xls_fn = $opts{f} || fetch_xlsx_file();
100 my $tax_districts = parse_xlsx_file( $xls_fn );
102 update_cust_main_county( $tax_districts );
104 # print Dumper( \%opts );
106 # print Dumper( $tax_districts->[$_] );
109 log_info_and_warn( "Finished" );
113 sub update_cust_main_county {
114 my $tax_districts = shift;
117 # Working from an assumption tax tables may be loaded multiple times,
118 # each with a different tax class,
119 # re: comments on FS::geocode_Mixin::process_district_update
122 my %cust_main_county =
123 map { $_->district => $_ }
125 cust_main_county => {
126 district => { op => '!=', value => undef },
128 source => 'wa_sales',
129 taxclass => $opts{c},
133 # Work within a SQL transaction
134 local $FS::UID::AutoCommit = 0;
136 my $insert_count = 0;
137 my $update_count = 0;
139 for my $district ( @$tax_districts ) {
140 if ( my $row = $cust_main_county{ $district->{district} } ) {
142 $row->city( uc $district->{city} );
143 $row->county( uc $district->{county} );
144 $row->taxclass( $opts{c} || undef );
145 $row->taxname( $opts{t} || undef );
146 $row->tax( $district->{tax_combined} * 100 );
148 if ( my $error = $row->replace ) {
150 local $FS::UID::AutoCommit = 1;
153 "Error updating cust_main_county row %s for district %s: %s",
155 $district->{district},
163 my $row = FS::cust_main_county->new({
164 district => $district->{district},
165 city => uc $district->{city},
166 county => uc $district->{county},
169 taxclass => $opts{c} || undef,
170 taxname => $opts{t} || undef,
171 tax => $district->{tax_combined} * 100,
172 source => 'wa_sales',
175 if ( my $error = $row->insert ) {
177 local $FS::UID::AutoCommit = 1;
180 "Error inserting cust_main_county row for district %s: %s",
181 $district->{district},
186 $cust_main_county{ $district->{district} } = $row;
193 local $FS::UID::AutoCommit = 1;
196 "WA tax table update completed. Inserted %s rows, updated %s rows",
203 sub parse_xlsx_file {
204 my $parse_fn = shift;
206 # About the file format:
208 # The current spreadsheet contains the following @columns.
209 # Rows 1 and 2 are a marquee header
210 # Row 3 is the column labels. We will test these to detect
211 # changes in the data format
212 # Rows 4+ are the tax district data
214 # The "city" column is being parsed from "Location"
216 my @columns = qw( city county district tax_local tax_state tax_combined );
218 log_error_and_die( "Unable to access XLSX file: $parse_fn" )
221 my $xls_parser = Spreadsheet::XLSX->new( $parse_fn )
222 or log_error_and_die( "Error parsing XLSX file: $!" );
224 my $sheet = $xls_parser->{Worksheet}->[0]
225 or log_error_and_die(" Unable to access worksheet 1 in XLSX file" );
227 my $cells = $sheet->{Cells}
228 or log_error_and_die( "Unable to read cells in XLSX file" );
230 # Read the column labels and verify
232 map{ $columns[$_] => $cells->[2][$_]->{Val} }
233 0 .. scalar(@columns)-1;
235 my %expected_labels = (
238 district => 'Location Code',
239 tax_local => 'Local Rate',
240 tax_state => 'State Rate',
241 tax_combined => 'Combined Sales Tax',
246 grep { lc $labels{$_} ne lc $expected_labels{$_} }
249 my $error = "Error parsing XLS file - ".
250 "Data format may have been updated with WA DOR! ";
251 $error .= "Expected column $expected_labels{$_}, found $labels{$_}! "
253 log_error_and_die( $error );
256 # Parse the rows into an array of hashes
258 for my $row ( 3..$sheet->{MaxRow} ) {
260 map { $columns[$_] => $cells->[$row][$_]->{Val} }
261 0 .. scalar(@columns)-1
267 && $district{district} =~ /^\d+$/
268 && $district{tax_local} =~ /^\d?\.\d+$/
269 && $district{tax_state} =~ /^\d?\.\d+$/
270 && $district{tax_combined} =~ /^\d?\.\d+$/
273 # For some reason, city may contain line breaks!
274 $district{city} =~ s/[\r\n]//g;
276 push @districts, \%district;
279 "Non-usable row found in spreadsheet:\n" . Dumper( \%district )
285 log_error_and_die( "No \@districts found in data file!" )
289 sprintf "Parsed %s districts from data file", scalar @districts
295 sub fetch_xlsx_file {
296 # Download file to temporary storage, return filename
298 my $url_base = 'https://dor.wa.gov'.
299 '/sites/default/files/legacy/Docs/forms/ExcsTx/LocSalUseTx';
301 my $year = $opts{y} || DateTime->now->year;
302 my $quarter = $opts{q} || DateTime->now->quarter;
303 $year = substr( $year, 2, 2 ) if $year >= 1000;
304 my $fn = sprintf( 'ExcelLocalSlsUserates_%s_Q%s.xlsx', $year, $quarter );
305 my $url = "$url_base/$fn";
307 my $write_fn = "$temp_dir/$fn";
309 log_info_and_warn( "Begin download from url: $url" );
311 my $ua = LWP::UserAgent->new;
312 my $res = $ua->get( $url );
314 log_error_and_die( "Download error: ".$res->status_line )
315 unless $res->is_success;
318 eval { write_file( $write_fn, $res->decoded_content ); };
319 log_error_and_die( "Problem writing download to disk: $@" )
322 log_info_and_warn( "Temporary file: $write_fn" );
329 Tool to update city/district sales tax rates in I<cust_main_county> from
330 the Washington State Department of Revenue website.
332 Usage: [-f filename] [-c taxclass] [-t taxname] [-y year] [-q quarter]
335 -f filename Skip download, and process the specified filename
336 -c taxclass Update or create records within this taxclass
338 -t taxname Apply tax name value to created or updated records
339 defaults as conf value 'tax_district_taxname'
340 -y year Year for data file download
341 -q quarter Quarter of data file to download
347 sub log_info_and_warn {
348 my $log_message = shift;
349 warn "$log_message\n";
350 $log->info( $log_message );
353 sub log_warn_and_warn {
354 my $log_message = shift;
355 warn "$log_message\n";
356 $log->warn( $log_message );
359 sub log_error_and_die {
360 my $log_message = shift;
361 $log->error( $log_message );
362 die( "$log_message\n" );