+ # Currently only wa_sales is supported
+ my $tax_district_method = conf_tax_district_method();
+
+ return unless $tax_district_method;
+
+ if ( exists &{$tax_district_method} ) {
+ my $func = \&{$tax_district_method};
+ $func->();
+ } else {
+ my $log = FS::Log->new('tax_rate_update');
+ $log->error( "Unhandled tax_district_method($tax_district_method)" );
+ }
+
+}
+
+=head2 wa_sales
+
+Monthly: Update the complete WA state tax tables
+Every Run: Log errors for cust_location records without a district
+
+=cut
+
+sub wa_sales {
+
+ return
+ unless conf_tax_district_method()
+ && conf_tax_district_method() eq 'wa_sales';
+
+ my $dt_now = DateTime->now;
+ my $year = $dt_now->year;
+ my $quarter = $dt_now->quarter;
+
+ my $journal_label =
+ sprintf 'wa_sales_update_tax_table_%sQ%s', $year, $quarter;
+
+ unless ( FS::upgrade_journal->is_done( $journal_label ) ) {
+ local $@;
+
+ eval{ wa_sales_update_tax_table(); };
+ log_error_and_die( "Error updating tax tables: $@" )
+ if $@;
+ FS::upgrade_journal->set_done( $journal_label );
+ }
+
+ wa_sales_log_customer_without_tax_district();
+
+ '';
+
+}
+
+=head2 wa_sales_log_customer_without_tax_district
+
+For any cust_location records
+* In WA state
+* Attached to non cancelled packages
+* With no tax district
+
+Classify the tax district for the record using the WA State Dept of
+Revenue API. If this fails, generate an error into system log so
+address can be corrected
+
+=cut
+
+sub wa_sales_log_customer_without_tax_district {
+
+ return
+ unless conf_tax_district_method()
+ && conf_tax_district_method() eq 'wa_sales';
+
+ my %qsearch_cust_location = (
+ table => 'cust_location',
+ select => '
+ cust_location.locationnum,
+ cust_location.custnum,
+ cust_location.address1,
+ cust_location.city,
+ cust_location.state,
+ cust_location.zip
+ ',
+ addl_from => '
+ LEFT JOIN cust_main USING (custnum)
+ LEFT JOIN cust_pkg ON cust_location.locationnum = cust_pkg.locationnum
+ ',
+ extra_sql => sprintf(q{
+ WHERE cust_location.state = 'WA'
+ AND (
+ cust_location.district IS NULL
+ or cust_location.district = ''
+ )
+ AND cust_pkg.pkgnum IS NOT NULL
+ AND (
+ cust_pkg.cancel > %s
+ OR cust_pkg.cancel IS NULL
+ )
+ }, time()
+ ),
+ );
+
+ for my $cust_location ( qsearch( \%qsearch_cust_location )) {
+ local $@;
+ log_info_and_warn(
+ sprintf
+ 'Attempting to classify district for cust_location ' .
+ 'locationnum(%s) address(%s)',
+ $cust_location->locationnum,
+ $cust_location->address1,
+ );
+
+ eval {
+ FS::geocode_Mixin::process_district_update(
+ 'FS::cust_location',
+ $cust_location->locationnum
+ );
+ };
+
+ if ( $@ ) {
+ # Error indicates a crash, not an error looking up district
+ # process_district_udpate will generate log messages for those errors
+ log_error_and_warn(
+ sprintf "Classify district error for cust_location(%s): %s",
+ $cust_location->locationnum,
+ $@
+ );
+ }
+
+ sleep 1; # Be polite to WA DOR API
+ }
+
+ for my $cust_location ( qsearch( \%qsearch_cust_location )) {
+ log_error_and_warn(
+ sprintf
+ "Customer address in WA lacking tax district classification. ".
+ "custnum(%s) ".
+ "locationnum(%s) ".
+ "address(%s, %s %s, %s) ".
+ "[https://webgis.dor.wa.gov/taxratelookup/SalesTax.aspx]",
+ map { $cust_location->$_ }
+ qw( custnum locationnum address1 city state zip )
+ );
+ }
+
+}
+
+
+=head2 wa_sales_update_tax_table \%args
+
+Update city/district sales tax rates in L<FS::cust_main_county> from the
+Washington State Department of Revenue published data files.
+
+Creates, or updates, a L<FS::cust_main_county> row for every tax district
+in Washington state. Some cities have different tax rates based on the
+address, within the city. Because of this, some cities have multiple
+districts.
+
+If tax classes are enabled, a row is created in every tax class for
+every district.
+
+Customer addresses aren't classified into districts here. Instead,
+when a Washington state address is inserted or changed in L<FS::cust_location>,
+a job is queued for FS::geocode_Mixin::process_district_update, to ask the
+Washington state API which tax district to use for this address.
+
+All arguments are optional:
+
+ filename: Skip file download, and process the specified filename instead
+
+ taxname: Updated or created records will be set to the given tax name.
+ If not specified, conf value 'tax_district_taxname' is used
+
+ year: Specify year for tax table download. Defaults to current year
+
+ quarter: Specify quarter for tax table download. Defaults to current quarter
+
+=head3 Washington State Department of Revenue Resources
+
+The state of Washington makes data files available via their public website.
+It's possible the availability or format of these files may change. As of now,
+the only data file that contains both city and county names is published in
+XLSX format.
+
+=over 4
+
+=item WA Dept of Revenue
+
+https://dor.wa.gov
+
+=item Data file downloads
+
+https://dor.wa.gov/find-taxes-rates/sales-and-use-tax-rates/downloadable-database
+
+=item XLSX file example
+
+https://dor.wa.gov/sites/default/files/legacy/Docs/forms/ExcsTx/LocSalUseTx/ExcelLocalSlsUserates_19_Q1.xlsx
+
+=item CSV file example
+
+https://dor.wa.gov/sites/default/files/legacy/downloads/Add_DataRates2018Q4.zip
+
+
+=item Address lookup API tool
+
+http://webgis.dor.wa.gov/webapi/AddressRates.aspx?output=xml&addr=410 Terry Ave. North&city=&zip=98100
+
+=back
+
+=cut
+
+sub wa_sales_update_tax_table {
+ my $args = shift;
+
+ croak 'wa_sales_update_tax_table requires \$args hashref'
+ if $args && !ref $args;
+
+ return
+ unless conf_tax_district_method()
+ && conf_tax_district_method() eq 'wa_sales';
+
+ $args->{taxname} ||= FS::Conf->new->config('tax_district_taxname');
+ $args->{year} ||= DateTime->now->year;
+ $args->{quarter} ||= DateTime->now->quarter;
+
+ log_info_and_warn(
+ "Begin wa_sales_update_tax_table() ".
+ join ', ' => (
+ map{ "$_ => ". ( $args->{$_} || 'undef' ) }
+ sort keys %$args
+ )
+ );
+
+ unless ( wa_sales_update_tax_table_sanity_check() ) {
+ log_error_and_die(
+ 'Duplicate district rows exist in the Washington state sales tax table. '.
+ 'These must be resolved before updating the tax tables. '.
+ 'See "freeside-wa-tax-table-resolve --check" to repair the tax tables. '
+ );
+ }
+
+ $args->{temp_dir} ||= tempdir();
+
+ $args->{filename} ||= wa_sales_fetch_xlsx_file( $args );
+
+ $args->{tax_districts} = wa_sales_parse_xlsx_file( $args );
+
+ wa_sales_update_cust_main_county( $args );
+
+ log_info_and_warn( 'Finished wa_sales_update_tax_table()' );
+}
+
+=head2 wa_sales_update_cust_main_county \%args
+
+Create or update the L<FS::cust_main_county> records with new data
+
+=cut
+
+sub wa_sales_update_cust_main_county {
+ my $args = shift;
+
+ return
+ unless conf_tax_district_method()
+ && conf_tax_district_method() eq 'wa_sales';
+
+ croak 'wa_sales_update_cust_main_county requires $args hashref'
+ unless ref $args
+ && ref $args->{tax_districts};
+
+ my $insert_count = 0;
+ my $update_count = 0;
+ my $same_count = 0;
+
+ $args->{taxname} ||= 'State Sales Tax';
+
+ # Work within a SQL transaction