summaryrefslogtreecommitdiff
path: root/FS/FS
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2016-11-07 16:24:16 -0800
committerMark Wells <mark@freeside.biz>2016-11-07 16:25:48 -0800
commit863c878cadb95fcad0603f66298473841340926b (patch)
treed310dd9fc5c81a4ed394a28cc8ebb34586af1526 /FS/FS
parent33235cef6314b6a79e1e91829bdae6e4e391720f (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.pm7
-rwxr-xr-xFS/FS/Cron/tax_rate_update.pm111
-rw-r--r--FS/FS/geocode_Mixin.pm122
-rw-r--r--FS/FS/log_context.pm1
-rw-r--r--FS/FS/part_pkg_taxclass.pm22
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