diff options
author | Mark Wells <mark@freeside.biz> | 2016-11-07 16:24:16 -0800 |
---|---|---|
committer | Mark Wells <mark@freeside.biz> | 2016-11-07 16:25:48 -0800 |
commit | 863c878cadb95fcad0603f66298473841340926b (patch) | |
tree | d310dd9fc5c81a4ed394a28cc8ebb34586af1526 /FS/FS | |
parent | 33235cef6314b6a79e1e91829bdae6e4e391720f (diff) |
revise process for updating WA sales taxes, #73185 and #73226
Conflicts:
FS/FS/Conf.pm
Diffstat (limited to 'FS/FS')
-rw-r--r-- | FS/FS/Conf.pm | 7 | ||||
-rwxr-xr-x | FS/FS/Cron/tax_rate_update.pm | 111 | ||||
-rw-r--r-- | FS/FS/geocode_Mixin.pm | 122 | ||||
-rw-r--r-- | FS/FS/log_context.pm | 1 | ||||
-rw-r--r-- | FS/FS/part_pkg_taxclass.pm | 22 |
5 files changed, 206 insertions, 57 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index d9d43a1..66cd1d7 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -4462,6 +4462,13 @@ and customer address. Include units.', }, { + 'key' => 'tax_district_taxname', + 'section' => 'taxation', + 'description' => 'The tax name to display on the invoice for district sales taxes. Defaults to "Tax".', + 'type' => 'text', + }, + + { 'key' => 'company_latitude', 'section' => 'taxation', 'description' => 'For Avalara taxation, your company latitude (-90 through 90)', diff --git a/FS/FS/Cron/tax_rate_update.pm b/FS/FS/Cron/tax_rate_update.pm new file mode 100755 index 0000000..e345964 --- /dev/null +++ b/FS/FS/Cron/tax_rate_update.pm @@ -0,0 +1,111 @@ +#!/usr/bin/perl + +=head1 NAME + +FS::Cron::tax_rate_update + +=head1 DESCRIPTION + +Cron routine to update city/district sales tax rates in I<cust_main_county>. +Currently supports sales tax in the state of Washington. + +=cut + +use strict; +use warnings; +use FS::Conf; +use FS::Record qw(qsearch qsearchs dbh); +use FS::cust_main_county; +use FS::part_pkg_taxclass; +use DateTime; +use LWP::UserAgent; +use File::Temp 'tempdir'; +use File::Slurp qw(read_file write_file); +use Text::CSV; +use Exporter; + +our @EXPORT_OK = qw(tax_rate_update); +our $DEBUG = 0; + +sub tax_rate_update { + my %opt = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $conf = FS::Conf->new; + my $method = $conf->config('tax_district_method'); + return if !$method; + + my $taxname = $conf->config('tax_district_taxname') || ''; + + if ($method eq 'wa_sales') { + # download the update file + my $now = DateTime->now; + my $yr = $now->year; + my $qt = $now->quarter; + my $file = "Rates${yr}Q${qt}.zip"; + my $url = 'http://dor.wa.gov/downloads/Add_Data/'.$file; + my $dir = tempdir(); + chdir($dir); + my $ua = LWP::UserAgent->new; + warn "Downloading $url...\n" if $DEBUG; + my $response = $ua->get($url); + if ( ! $response->is_success ) { + die $response->status_line; + } + write_file($file, $response->decoded_content); + + # parse it + system('unzip', $file); + $file =~ s/\.zip$/.csv/; + if (! -f $file) { + die "$file not found in zip archive.\n"; + } + open my $fh, '<', $file + or die "couldn't open $file: $!\n"; + my $csv = Text::CSV->new; + my $header = $csv->getline($fh); + $csv->column_names(@$header); + # columns we care about are headed 'Code' and 'Rate' + + my $total_changed = 0; + my $total_skipped = 0; + while ( !$csv->eof ) { + my $line = $csv->getline_hr($fh); + my $district = $line->{Code} or next; + $district = sprintf('%04d', $district); + my $tax = sprintf('%.1f', $line->{Rate} * 100); + my $changed = 0; + my $skipped = 0; + # find rate(s) in this country+state+district+taxclass that have the + # wa_sales flag and the configured taxname, and haven't been disabled. + my @rates = qsearch('cust_main_county', { + country => 'US', + state => 'WA', # this is specific to WA + district => $district, + taxname => $taxname, + source => 'wa_sales', + tax => { op => '>', value => '0' }, + }); + foreach my $rate (@rates) { + if ( $rate->tax == $tax ) { + $skipped++; + } else { + $rate->set('tax', $tax); + my $error = $rate->replace; + die "error updating district $district: $error\n" if $error; + $changed++; + } + } # foreach $taxclass + print "$district: updated $changed, skipped $skipped\n" + if $DEBUG and ($changed or $skipped); + $total_changed += $changed; + $total_skipped += $skipped; + } + print "Updated $total_changed tax rates.\nSkipped $total_skipped unchanged rates.\n" if $DEBUG; + dbh->commit; + } # else $method isn't wa_sales, no other methods exist yet + ''; +} diff --git a/FS/FS/geocode_Mixin.pm b/FS/FS/geocode_Mixin.pm index 09b1131..46f8128 100644 --- a/FS/FS/geocode_Mixin.pm +++ b/FS/FS/geocode_Mixin.pm @@ -11,6 +11,7 @@ use FS::cust_pkg; use FS::cust_location; use FS::cust_tax_location; use FS::part_pkg; +use FS::part_pkg_taxclass; $DEBUG = 0; $me = '[FS::geocode_Mixin]'; @@ -253,8 +254,7 @@ Queueable function to update the tax district code using the selected method sub process_district_update { my $class = shift; my $id = shift; - - local $DEBUG = 1; + my $log = FS::Log->new('FS::cust_location::process_district_update'); eval "use FS::Misc::Geo qw(get_district); use FS::Conf; use $class;"; die $@ if $@; @@ -267,68 +267,78 @@ sub process_district_update { # 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; + return unless $tax_info; + + $self->set('district', $tax_info->{'district'} ); + my $error = $self->replace; + die $error if $error; + + my %hash = map { $_ => uc( $tax_info->{$_} ) } + qw( district city county state country ); + $hash{'source'} = $method; # apply the update only to taxes we maintain + + my @classes = FS::part_pkg_taxclass->taxclass_names; + my $taxname = $conf->config('tax_district_taxname'); + # there must be exactly one cust_main_county for each district+taxclass. + # do NOT exclude taxes that are zero. + foreach my $taxclass (@classes) { + my @existing = qsearch('cust_main_county', { + %hash, + 'taxclass' => $taxclass + }); + + if ( scalar(@existing) == 0 ) { + + # then create one with the assigned tax name, and the tax rate from + # the lookup. + my $new = new FS::cust_main_county({ + %hash, + 'taxclass' => $taxclass, + 'taxname' => $taxname, + 'tax' => $tax_info->{tax}, + 'exempt_amount' => 0, + }); + $log->info("creating tax rate for district ".$tax_info->{'district'}); + $error = $new->insert; - my %hash = map { $_ => uc( $tax_info->{$_} ) } - qw( district city county state country ); - $hash{'source'} = $method; # apply the update only to taxes we maintain - - my @old = qsearch('cust_main_county', \%hash); - if ( @old ) { - # prune any duplicates rather than updating them - my %keep; # key => cust_main_county record - foreach my $cust_main_county (@old) { - my $key = join('.', $cust_main_county->city , - $cust_main_county->district , - $cust_main_county->taxclass - ); - if ( exists $keep{$key} ) { - my $disable_this = $cust_main_county; - # prefer records that have a tax name - if ( $cust_main_county->taxname and not $keep{$key}->taxname ) { - $disable_this = $keep{$key}; - $keep{$key} = $cust_main_county; + } else { + + my $to_update = $existing[0]; + # if there's somehow more than one, find the best candidate to be + # updated: + # - prefer tax > 0 over tax = 0 (leave disabled records disabled) + # - then, prefer taxname = the designated taxname + if ( scalar(@existing) > 1 ) { + $log->warning("tax district ".$tax_info->{district}." has multiple $method taxes."); + foreach (@existing) { + if ( $to_update->tax == 0 ) { + if ( $_->tax > 0 and $to_update->tax == 0 ) { + $to_update = $_; + } elsif ( $_->tax == 0 and $to_update->tax > 0 ) { + next; + } elsif ( $_->taxname eq $taxname and $to_update->tax ne $taxname ) { + $to_update = $_; + } } - # disable by setting the rate to zero, and setting source to null - # so it doesn't get auto-updated in the future. don't actually - # delete it, that produces orphan records - warn "disabling tax rate #" . - $disable_this->taxnum . - " because it's a duplicate for $key\n" - if $DEBUG; - # by setting its rate to zero, and never updating - # it again - $disable_this->set('tax' => 0); - $disable_this->set('source' => ''); - $error = $disable_this->replace; - die $error if $error; } - - $keep{$key} ||= $cust_main_county; - + # don't remove the excess records here; upgrade does that. } - foreach my $key (keys %keep) { - my $cust_main_county = $keep{$key}; - warn "updating tax rate #".$cust_main_county->taxnum. - " for $key" if $DEBUG; - # update the tax rate only - $cust_main_county->set('tax', $tax_info->{'tax'}); - $error ||= $cust_main_county->replace; + my $taxnum = $to_update->taxnum; + if ( $to_update->tax == 0 ) { + $log->debug("tax#$taxnum is set to zero; not updating."); + } elsif ( $to_update->tax == $tax_info->{tax} ) { + # do nothing, no need to update + } else { + $to_update->set('tax', $tax_info->{tax}); + $log->info("updating tax#$taxnum with new rate ($tax_info->{tax})."); + $error = $to_update->replace; } - } else { - # make a new tax record, and mark it so we can find it later - $tax_info->{'source'} = $method; - 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; - } + } # foreach $taxclass + return; } diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index 284a780..afd67cc 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -15,6 +15,7 @@ my @contexts = ( qw( FS::Misc::Geo::standardize_uscensus FS::saved_search::send FS::saved_search::render + FS::cust_location::process_district_update Cron::bill Cron::backup Cron::upload diff --git a/FS/FS/part_pkg_taxclass.pm b/FS/FS/part_pkg_taxclass.pm index 055c778..d8ddb15 100644 --- a/FS/FS/part_pkg_taxclass.pm +++ b/FS/FS/part_pkg_taxclass.pm @@ -4,7 +4,7 @@ use strict; use vars qw( @ISA ); use Scalar::Util qw( blessed ); use FS::UID qw( dbh ); -use FS::Record; # qw( qsearch qsearchs ); +use FS::Record qw(qsearch); # qsearchs ); use FS::cust_main_county; @ISA = qw(FS::Record); @@ -219,6 +219,26 @@ sub _upgrade_data { # class method } +=head1 CLASS METHODS + +=over 4 + +=item taxclass_names + +Returns a list of all the non-disabled tax classes. If tax classes aren't +enabled, returns a single empty string. + +=cut + +sub taxclass_names { + if ( FS::Conf->new->exists('enable_taxclasses') ) { + return map { $_->get('taxclass') } + qsearch('part_pkg_taxclass', { disabled => '' }); + } else { + return ( '' ); + } +} + =head1 BUGS Other tables (cust_main_county, part_pkg, agent_payment_gateway) have a text |