+=item process_batch_import
+
+Load a batch import as a queued JSRPC job
+
+=cut
+
+sub process_batch_import {
+ my $job = shift;
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ my $param = thaw(decode_base64(shift));
+ my $args = '$job, encode_base64( nfreeze( $param ) )';
+
+ my $method = '_perform_batch_import';
+ if ( $param->{reload} ) {
+ $method = 'process_batch_reload';
+ }
+
+ eval "$method($args);";
+ if ($@) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $@;
+ }
+
+ #success!
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+}
+
+sub _perform_batch_import {
+ my $job = shift;
+
+ my $param = thaw(decode_base64(shift));
+ my $format = $param->{'format'}; #well... this is all cch specific
+
+ my $files = $param->{'uploaded_files'}
+ or die "No files provided.";
+
+ my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
+ split /,/, $files;
+
+ if ( $format eq 'cch' || $format eq 'cch-fixed'
+ || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
+ {
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+ my $error = '';
+ my @insert_list = ();
+ my @delete_list = ();
+ my @predelete_list = ();
+ my $insertname = '';
+ my $deletename = '';
+ my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
+
+ my @list = ( 'GEOCODE', \&FS::tax_rate_location::batch_import,
+ 'CODE', \&FS::tax_class::batch_import,
+ 'PLUS4', \&FS::cust_tax_location::batch_import,
+ 'ZIP', \&FS::cust_tax_location::batch_import,
+ 'TXMATRIX', \&FS::part_pkg_taxrate::batch_import,
+ 'DETAIL', \&FS::tax_rate::batch_import,
+ );
+ while( scalar(@list) ) {
+ my ( $name, $import_sub ) = splice( @list, 0, 2 );
+ my $file = lc($name). 'file';
+
+ unless ($files{$file}) {
+ $error = "No $name supplied";
+ next;
+ }
+ next if $name eq 'DETAIL' && $format =~ /update/;
+
+ my $filename = "$dir/". $files{$file};
+
+ if ( $format =~ /update/ ) {
+
+ ( $error, $insertname, $deletename ) =
+ _perform_cch_insert_delete_split( $name, $filename, $dir, $format )
+ unless $error;
+ last if $error;
+
+ unlink $filename or warn "Can't delete $filename: $!"
+ unless $keep_cch_files;
+ push @insert_list, $name, $insertname, $import_sub, $format;
+ if ( $name eq 'GEOCODE' ) { #handle this whole ordering issue better
+ unshift @predelete_list, $name, $deletename, $import_sub, $format;
+ } else {
+ unshift @delete_list, $name, $deletename, $import_sub, $format;
+ }
+
+ } else {
+
+ push @insert_list, $name, $filename, $import_sub, $format;
+
+ }
+
+ }
+
+ push @insert_list,
+ 'DETAIL', "$dir/".$files{detail}, \&FS::tax_rate::batch_import, $format
+ if $format =~ /update/;
+
+ $error ||= _perform_cch_tax_import( $job,
+ [ @predelete_list ],
+ [ @insert_list ],
+ [ @delete_list ],
+ );
+
+
+ @list = ( @predelete_list, @insert_list, @delete_list );
+ while( !$keep_cch_files && scalar(@list) ) {
+ my ( undef, $file, undef, undef ) = splice( @list, 0, 4 );
+ unlink $file or warn "Can't delete $file: $!";
+ }
+
+ if ($error) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }else{
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ }
+
+ }else{
+ die "Unknown format: $format";
+ }
+
+}
+
+
+sub _perform_cch_tax_import {
+ my ( $job, $predelete_list, $insert_list, $delete_list ) = @_;
+
+ my $error = '';
+ foreach my $list ($predelete_list, $insert_list, $delete_list) {
+ while( scalar(@$list) ) {
+ my ( $name, $file, $method, $format ) = splice( @$list, 0, 4 );
+ my $fmt = "$format-update";
+ $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
+ open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
+ $error ||= &{$method}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
+ close $fh;
+ }
+ }
+
+ return $error;
+}
+
+sub _perform_cch_insert_delete_split {
+ my ($name, $filename, $dir, $format) = @_;
+
+ my $error = '';
+
+ open my $fh, "< $filename"
+ or $error ||= "Can't open $name file $filename: $!";
+
+ my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
+ DIR => $dir,
+ UNLINK => 0, #meh
+ ) or die "can't open temp file: $!\n";
+ my $insertname = $ifh->filename;
+
+ my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
+ DIR => $dir,
+ UNLINK => 0, #meh
+ ) or die "can't open temp file: $!\n";
+ my $deletename = $dfh->filename;
+
+ my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
+ my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
+ while(<$fh>) {
+ my $handle = '';
+ $handle = $ifh if $_ =~ /$insert_pattern/;
+ $handle = $dfh if $_ =~ /$delete_pattern/;
+ unless ($handle) {
+ $error = "bad input line: $_" unless $handle;
+ last;
+ }
+ print $handle $_;
+ }
+ close $fh;
+ close $ifh;
+ close $dfh;
+
+ return ($error, $insertname, $deletename);
+}
+
+sub _perform_cch_diff {
+ my ($name, $newdir, $olddir) = @_;
+
+ my %oldlines = ();
+
+ if ($olddir) {
+ open my $oldcsvfh, "$olddir/$name.txt"
+ or die "failed to open $olddir/$name.txt: $!\n";
+
+ while(<$oldcsvfh>) {
+ chomp;
+ $oldlines{$_} = 1;
+ }
+ close $oldcsvfh;
+ }
+
+ open my $newcsvfh, "$newdir/$name.txt"
+ or die "failed to open $newdir/$name.txt: $!\n";
+
+ my $dfh = new File::Temp( TEMPLATE => "$name.diff.XXXXXXXX",
+ DIR => "$newdir",
+ UNLINK => 0, #meh
+ ) or die "can't open temp file: $!\n";
+ my $diffname = $dfh->filename;
+
+ while(<$newcsvfh>) {
+ chomp;
+ if (exists($oldlines{$_})) {
+ $oldlines{$_} = 0;
+ } else {
+ print $dfh $_, ',"I"', "\n";
+ }
+ }
+ close $newcsvfh;
+
+ for (keys %oldlines) {
+ print $dfh $_, ',"D"', "\n" if $oldlines{$_};
+ }
+
+ close $dfh;
+
+ return $diffname;
+}
+
+sub _cch_fetch_and_unzip {
+ my ( $job, $urls, $secret, $dir ) = @_;
+
+ my $ua = new LWP::UserAgent;
+ foreach my $url (split ',', $urls) {
+ my @name = split '/', $url; #somewhat restrictive
+ my $name = pop @name;
+ $name =~ /([\w.]+)/; # untaint that which we don't trust so much any more
+ $name = $1;
+
+ open my $taxfh, ">$dir/$name" or die "Can't open $dir/$name: $!\n";
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+ my $res = $ua->request(
+ new HTTP::Request( GET => $url ),
+ sub {
+ print $taxfh $_[0] or die "Can't write to $dir/$name: $!\n";
+ my $content_length = $_[1]->content_length;
+ $imported += length($_[0]);
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ ($content_length ? int(100 * $imported/$content_length) : 0 ).
+ ",Downloading data from CCH"
+ );
+ die $error if $error;
+ $last = time;
+ }
+ },
+ );
+ die "download of $url failed: ". $res->status_line
+ unless $res->is_success;
+
+ close $taxfh;
+ my $error = $job->update_statustext( "0,Unpacking data" );
+ die $error if $error;
+ $secret =~ /([\w.]+)/; # untaint that which we don't trust so much any more
+ $secret = $1;
+ system('unzip', "-P", $secret, "-d", "$dir", "$dir/$name") == 0
+ or die "unzip -P $secret -d $dir $dir/$name failed";
+ #unlink "$dir/$name";
+ }
+}
+
+sub _cch_extract_csv_from_dbf {
+ my ( $job, $dir, $name ) = @_;
+
+ eval "use Text::CSV_XS;";
+ die $@ if $@;
+
+ eval "use XBase;";
+ die $@ if $@;
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+ my $error = $job->update_statustext( "0,Unpacking $name" );
+ die $error if $error;
+ warn "opening $dir.new/$name.dbf\n" if $DEBUG;
+ my $table = new XBase 'name' => "$dir.new/$name.dbf";
+ die "failed to access $dir.new/$name.dbf: ". XBase->errstr
+ unless defined($table);
+ my $count = $table->last_record; # approximately;
+ open my $csvfh, ">$dir.new/$name.txt"
+ or die "failed to open $dir.new/$name.txt: $!\n";
+
+ my $csv = new Text::CSV_XS { 'always_quote' => 1 };
+ my @fields = $table->field_names;
+ my $cursor = $table->prepare_select;
+ my $format_date =
+ sub { my $date = shift;
+ $date =~ /^(\d{4})(\d{2})(\d{2})$/ && ($date = "$2/$3/$1");
+ $date;
+ };
+ while (my $row = $cursor->fetch_hashref) {
+ $csv->combine( map { ($table->field_type($_) eq 'D')
+ ? &{$format_date}($row->{$_})
+ : $row->{$_}
+ }
+ @fields
+ );
+ print $csvfh $csv->string, "\n";
+ $imported++;
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ int(100 * $imported/$count). ",Unpacking $name"
+ );
+ die $error if $error;
+ $last = time;
+ }
+ }
+ $table->close;
+ close $csvfh;
+}
+
+sub _remember_disabled_taxes {
+ my ( $job, $format, $disabled_tax_rate ) = @_;
+
+ # cch specific hash
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+
+ my @items = qsearch( { table => 'tax_rate',
+ hashref => { disabled => 'Y',
+ data_vendor => $format,
+ },
+ select => 'geocode, taxclassnum',
+ }
+ );
+ my $count = scalar(@items);
+ foreach my $tax_rate ( @items ) {
+ if ( time - $min_sec > $last ) {
+ $job->update_statustext(
+ int( 100 * $imported / $count ). ",Remembering disabled taxes"
+ );
+ $last = time;
+ }
+ $imported++;
+ my $tax_class =
+ qsearchs( 'tax_class', { taxclassnum => $tax_rate->taxclassnum } );
+ unless ( $tax_class ) {
+ warn "failed to find tax_class ". $tax_rate->taxclassnum;
+ next;
+ }
+ $disabled_tax_rate->{$tax_rate->geocode. ':'. $tax_class->taxclass} = 1;
+ }
+}
+
+sub _remember_tax_products {
+ my ( $job, $format, $taxproduct ) = @_;
+
+ # XXX FIXME this loop only works when cch is the only data provider
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+
+ my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
+ "0 < ( SELECT count(*) from part_pkg_option WHERE ".
+ " part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
+ " optionname LIKE 'usage_taxproductnum_%' AND ".
+ " optionvalue != '' )";
+ my @items = qsearch( { table => 'part_pkg',
+ select => 'DISTINCT pkgpart,taxproductnum',
+ hashref => {},
+ extra_sql => $extra_sql,
+ }
+ );
+ my $count = scalar(@items);
+ foreach my $part_pkg ( @items ) {
+ if ( time - $min_sec > $last ) {
+ $job->update_statustext(
+ int( 100 * $imported / $count ). ",Remembering tax products"
+ );
+ $last = time;
+ }
+ $imported++;
+ warn "working with package part ". $part_pkg->pkgpart.
+ "which has a taxproductnum of ". $part_pkg->taxproductnum. "\n" if $DEBUG;
+ my $part_pkg_taxproduct = $part_pkg->taxproduct('');
+ $taxproduct->{$part_pkg->pkgpart}->{''} = $part_pkg_taxproduct->taxproduct
+ if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
+
+ foreach my $option ( $part_pkg->part_pkg_option ) {
+ next unless $option->optionname =~ /^usage_taxproductnum_(\w+)$/;
+ my $class = $1;
+
+ $part_pkg_taxproduct = $part_pkg->taxproduct($class);
+ $taxproduct->{$part_pkg->pkgpart}->{$class} =
+ $part_pkg_taxproduct->taxproduct
+ if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
+ }
+ }
+}
+
+sub _restore_remembered_tax_products {
+ my ( $job, $format, $taxproduct ) = @_;
+
+ # cch specific
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+ my $count = scalar(keys %$taxproduct);
+ foreach my $pkgpart ( keys %$taxproduct ) {
+ warn "restoring taxproductnums on pkgpart $pkgpart\n" if $DEBUG;
+ if ( time - $min_sec > $last ) {
+ $job->update_statustext(
+ int( 100 * $imported / $count ). ",Restoring tax products"
+ );
+ $last = time;
+ }
+ $imported++;
+
+ my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkgpart } );
+ unless ( $part_pkg ) {
+ return "somehow failed to find part_pkg with pkgpart $pkgpart!\n";
+ }
+
+ my %options = $part_pkg->options;
+ my %pkg_svc = map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc;
+ my $primary_svc = $part_pkg->svcpart;
+ my $new = new FS::part_pkg { $part_pkg->hash };
+
+ foreach my $class ( keys %{ $taxproduct->{$pkgpart} } ) {
+ warn "working with class '$class'\n" if $DEBUG;
+ my $part_pkg_taxproduct =
+ qsearchs( 'part_pkg_taxproduct',
+ { taxproduct => $taxproduct->{$pkgpart}->{$class},
+ data_vendor => $format,
+ }
+ );
+
+ unless ( $part_pkg_taxproduct ) {
+ return "failed to find part_pkg_taxproduct (".
+ $taxproduct->{pkgpart}->{$class}. ") for pkgpart $pkgpart\n";
+ }
+
+ if ( $class eq '' ) {
+ $new->taxproductnum($part_pkg_taxproduct->taxproductnum);
+ next;
+ }
+
+ $options{"usage_taxproductnum_$class"} =
+ $part_pkg_taxproduct->taxproductnum;
+
+ }
+
+ my $error = $new->replace( $part_pkg,
+ 'pkg_svc' => \%pkg_svc,
+ 'primary_svc' => $primary_svc,
+ 'options' => \%options,
+ );
+
+ return $error if $error;
+
+ }
+
+ '';
+}
+
+sub _restore_remembered_disabled_taxes {
+ my ( $job, $format, $disabled_tax_rate ) = @_;
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+ my $count = scalar(keys %$disabled_tax_rate);
+ foreach my $key (keys %$disabled_tax_rate) {
+ if ( time - $min_sec > $last ) {
+ $job->update_statustext(
+ int( 100 * $imported / $count ). ",Disabling tax rates"
+ );
+ $last = time;
+ }
+ $imported++;
+ my ($geocode,$taxclass) = split /:/, $key, 2;
+ my @tax_class = qsearch( 'tax_class', { data_vendor => $format,
+ taxclass => $taxclass,
+ } );
+ return "found multiple tax_class records for format $format class $taxclass"
+ if scalar(@tax_class) > 1;
+
+ unless (scalar(@tax_class)) {
+ warn "no tax_class for format $format class $taxclass\n";
+ next;
+ }
+
+ my @tax_rate =
+ qsearch('tax_rate', { data_vendor => $format,
+ geocode => $geocode,
+ taxclassnum => $tax_class[0]->taxclassnum,
+ }
+ );
+
+ if (scalar(@tax_rate) > 1) {
+ return "found multiple tax_rate records for format $format geocode ".
+ "$geocode and taxclass $taxclass ( taxclassnum ".
+ $tax_class[0]->taxclassnum. " )";
+ }
+
+ if (scalar(@tax_rate)) {
+ $tax_rate[0]->disabled('Y');
+ my $error = $tax_rate[0]->replace;
+ return $error if $error;
+ }
+ }
+}
+
+sub _remove_old_tax_data {
+ my ( $job, $format ) = @_;
+
+ my $dbh = dbh;
+ my $error = $job->update_statustext( "0,Removing old tax data" );
+ die $error if $error;
+
+ my $sql = "UPDATE public.tax_rate_location SET disabled='Y' ".
+ "WHERE data_vendor = ". $dbh->quote($format);
+ $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
+
+ my @table = qw(
+ tax_rate part_pkg_taxrate part_pkg_taxproduct tax_class cust_tax_location
+ );
+ foreach my $table ( @table ) {
+ $sql = "DELETE FROM public.$table WHERE data_vendor = ".
+ $dbh->quote($format);
+ $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
+ }
+
+ if ( $format eq 'cch' ) {
+ $sql = "DELETE FROM public.cust_tax_location WHERE data_vendor = ".
+ $dbh->quote("$format-zip");
+ $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
+ }
+
+ '';
+}
+
+sub _create_temporary_tables {
+ my ( $job, $format ) = @_;
+
+ my $dbh = dbh;
+ my $error = $job->update_statustext( "0,Creating temporary tables" );
+ die $error if $error;
+
+ my @table = qw( tax_rate
+ tax_rate_location
+ part_pkg_taxrate
+ part_pkg_taxproduct
+ tax_class
+ cust_tax_location
+ );
+ foreach my $table ( @table ) {
+ my $sql =
+ "CREATE TEMPORARY TABLE $table ( LIKE $table INCLUDING DEFAULTS )";
+ $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
+ }
+
+ '';
+}
+
+sub _copy_from_temp {
+ my ( $job, $format ) = @_;
+
+ my $dbh = dbh;
+ my $error = $job->update_statustext( "0,Making permanent" );
+ die $error if $error;
+
+ my @table = qw( tax_rate
+ tax_rate_location
+ part_pkg_taxrate
+ part_pkg_taxproduct
+ tax_class
+ cust_tax_location
+ );
+ foreach my $table ( @table ) {
+ my $sql =
+ "INSERT INTO public.$table SELECT * from $table";
+ $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
+ }
+
+ '';
+}
+
+=item process_download_and_reload
+
+Download and process a tax update as a queued JSRPC job after wiping the
+existing wipable tax data.
+
+=cut
+
+sub process_download_and_reload {
+ _process_reload('process_download_and_update', @_);
+}
+
+
+=item process_batch_reload
+
+Load and process a tax update from the provided files as a queued JSRPC job
+after wiping the existing wipable tax data.
+
+=cut
+
+sub process_batch_reload {
+ _process_reload('_perform_batch_import', @_);
+}
+
+
+sub _process_reload {
+ my ( $method, $job ) = ( shift, shift );
+
+ my $param = thaw(decode_base64($_[0]));
+ my $format = $param->{'format'}; #well... this is all cch specific
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+
+ if ( $job ) { # progress bar
+ my $error = $job->update_statustext( 0 );
+ die $error if $error;
+ }
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+ my $error = '';
+
+ my $sql =
+ "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
+ "USING (taxclassnum) WHERE data_vendor = '$format'";
+ my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+ $sth->execute
+ or die "Unexpected error executing statement $sql: ". $sth->errstr;
+ die "Don't (yet) know how to handle part_pkg_taxoverride records."
+ if $sth->fetchrow_arrayref->[0];
+
+ # really should get a table EXCLUSIVE lock here
+
+ #remember disabled taxes
+ my %disabled_tax_rate = ();
+ $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
+
+ #remember tax products
+ my %taxproduct = ();
+ $error ||= _remember_tax_products( $job, $format, \%taxproduct );
+
+ #create temp tables
+ $error ||= _create_temporary_tables( $job, $format );
+
+ #import new data
+ unless ($error) {
+ my $args = '$job, @_';
+ eval "$method($args);";
+ $error = $@ if $@;
+ }
+
+ #restore taxproducts
+ $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
+
+ #disable tax_rates
+ $error ||=
+ _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
+
+ #wipe out the old data
+ $error ||= _remove_old_tax_data( $job, $format );
+
+ #untemporize
+ $error ||= _copy_from_temp( $job, $format );
+
+ if ($error) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }
+
+ #success!
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+}
+
+
+=item process_download_and_update
+
+Download and process a tax update as a queued JSRPC job
+
+=cut
+
+sub process_download_and_update {
+ my $job = shift;
+
+ my $param = thaw(decode_base64(shift));
+ my $format = $param->{'format'}; #well... this is all cch specific
+
+ my ( $imported, $last, $min_sec ) = _progressbar_foo();
+
+ if ( $job ) { # progress bar
+ my $error = $job->update_statustext( 0);
+ die $error if $error;
+ }
+
+ my $cache_dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
+ my $dir = $cache_dir. 'taxdata';
+ unless (-d $dir) {
+ mkdir $dir or die "can't create $dir: $!\n";
+ }
+
+ if ($format eq 'cch') {
+
+ my @namelist = qw( code detail geocode plus4 txmatrix zip );
+
+ my $conf = new FS::Conf;
+ die "direct download of tax data not enabled\n"
+ unless $conf->exists('taxdatadirectdownload');
+ my ( $urls, $username, $secret, $states ) =
+ $conf->config('taxdatadirectdownload');
+ die "No tax download URL provided. ".
+ "Did you set the taxdatadirectdownload configuration value?\n"
+ unless $urls;
+
+ $dir .= '/cch';
+
+ my $dbh = dbh;
+ my $error = '';
+
+ # really should get a table EXCLUSIVE lock here
+ # check if initial import or update
+ #
+ # relying on mkdir "$dir.new" as a mutex
+
+ my $sql = "SELECT count(*) from tax_rate WHERE data_vendor='$format'";
+ my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+ $sth->execute() or die $sth->errstr;
+ my $update = $sth->fetchrow_arrayref->[0];
+
+ # create cache and/or rotate old tax data
+
+ if (-d $dir) {
+
+ if (-d "$dir.4") {
+ opendir(my $dirh, "$dir.4") or die "failed to open $dir.4: $!\n";
+ foreach my $file (readdir($dirh)) {
+ unlink "$dir.4/$file" if (-f "$dir.4/$file");
+ }
+ closedir($dirh);
+ rmdir "$dir.4";
+ }
+
+ for (3, 2, 1) {
+ if ( -e "$dir.$_" ) {
+ rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
+ }
+ }
+ rename "$dir", "$dir.1" or die "can't rename $dir: $!\n";
+
+ } else {
+
+ die "can't find previous tax data\n" if $update;
+
+ }
+
+ mkdir "$dir.new" or die "can't create $dir.new: $!\n";
+
+ # fetch and unpack the zip files
+
+ _cch_fetch_and_unzip( $job, $urls, $secret, "$dir.new" );
+
+ # extract csv files from the dbf files
+
+ foreach my $name ( @namelist ) {
+ _cch_extract_csv_from_dbf( $job, $dir, $name );
+ }
+
+ # generate the diff files
+
+ my @list = ();
+ foreach my $name ( @namelist ) {
+ my $difffile = "$dir.new/$name.txt";
+ if ($update) {
+ my $error = $job->update_statustext( "0,Comparing to previous $name" );
+ die $error if $error;
+ warn "processing $dir.new/$name.txt\n" if $DEBUG;
+ my $olddir = $update ? "$dir.1" : "";
+ $difffile = _perform_cch_diff( $name, "$dir.new", $olddir );
+ }
+ $difffile =~ s/^$cache_dir//;
+ push @list, "${name}file:$difffile";
+ }
+
+ # perform the import
+ local $keep_cch_files = 1;
+ $param->{uploaded_files} = join( ',', @list );
+ $param->{format} .= '-update' if $update;
+ $error ||=
+ _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
+
+ rename "$dir.new", "$dir"
+ or die "cch tax update processed, but can't rename $dir.new: $!\n";
+
+ }else{
+ die "Unknown format: $format";
+ }
+}
+
+=item browse_queries PARAMS
+
+Returns a list consisting of a hashref suited for use as the argument
+to qsearch, and sql query string. Each is based on the PARAMS hashref
+of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
+from a form. This conveniently creates the query hashref and count_query
+string required by the browse and search elements. As a side effect,
+the PARAMS hashref is untainted and keys with unexpected values are removed.
+
+=cut
+
+sub browse_queries {
+ my $params = shift;
+
+ my $query = {
+ 'table' => 'tax_rate',
+ 'hashref' => {},
+ 'order_by' => 'ORDER BY geocode, taxclassnum',
+ },
+
+ my $extra_sql = '';
+
+ if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
+ $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
+ } else {
+ delete $params->{data_vendor};
+ }
+
+ if ( $params->{geocode} =~ /^(\w+)$/ ) {
+ $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
+ 'geocode LIKE '. dbh->quote($1.'%');
+ } else {
+ delete $params->{geocode};
+ }
+
+ if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
+ qsearchs( 'tax_class', {'taxclassnum' => $1} )
+ )
+ {
+ $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
+ ' taxclassnum = '. dbh->quote($1)
+ } else {
+ delete $params->{taxclassnun};
+ }
+
+ my $tax_type = $1
+ if ( $params->{tax_type} =~ /^(\d+)$/ );
+ delete $params->{tax_type}
+ unless $tax_type;
+
+ my $tax_cat = $1
+ if ( $params->{tax_cat} =~ /^(\d+)$/ );
+ delete $params->{tax_cat}
+ unless $tax_cat;
+
+ my @taxclassnum = ();
+ if ($tax_type || $tax_cat ) {
+ my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
+ $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
+ @taxclassnum = map { $_->taxclassnum }
+ qsearch({ 'table' => 'tax_class',
+ 'hashref' => {},
+ 'extra_sql' => "WHERE taxclass $compare",
+ });
+ }
+
+ $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
+ join(' OR ', map { " taxclassnum = $_ " } @taxclassnum ). ' )'
+ if ( @taxclassnum );
+
+ unless ($params->{'showdisabled'}) {
+ $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
+ "( disabled = '' OR disabled IS NULL )";
+ }
+
+ $query->{extra_sql} = $extra_sql;
+
+ return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
+}
+
+=item queue_liability_report PARAMS
+
+Launches a tax liability report.
+=cut
+
+sub queue_liability_report {
+ my $cgi = shift;
+ my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
+ my $agentnum = $cgi->param('agentnum');
+ $agentnum =~ /^(\d+)$/ ? $agentnum = $1 : $agentnum = '';
+ my $job = new FS::queue { job => 'FS::tax_rate::generate_liability_report' };
+ $job->insert(
+ 'beginning' => $beginning,
+ 'ending' => $ending,
+ 'agentnum' => $agentnum,
+ 'p' => popurl(2),
+ 'rooturl' => rooturl,
+ );
+}
+
+=item generate_liability_report PARAMS
+
+Generates a tax liability report. Provide a hash including desired
+agentnum, beginning, and ending
+
+=cut
+
+sub generate_liability_report {
+ my %args = @_;
+
+ #let us open the temp file early
+ my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
+ my $report = new File::Temp( TEMPLATE => 'report.tax.liability.XXXXXXXX',
+ DIR => $dir,
+ UNLINK => 0, # not so temp
+ ) or die "can't open report file: $!\n";
+
+ my $conf = new FS::Conf;
+ my $money_char = $conf->config('money_char') || '$';
+
+ my $join_cust = "
+ JOIN cust_bill USING ( invnum )
+ LEFT JOIN cust_main USING ( custnum )
+ ";
+
+ my $join_loc =
+ "LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum )";
+ my $join_tax_loc = "LEFT JOIN tax_rate_location USING ( taxratelocationnum )";
+
+ my $addl_from = " $join_cust $join_loc $join_tax_loc ";
+
+ my $where = "WHERE _date >= $args{beginning} AND _date <= $args{ending} ";
+
+ my $agentname = '';
+ if ( $args{agentnum} =~ /^(\d+)$/ ) {
+ my $agent = qsearchs('agent', { 'agentnum' => $1 } );
+ die "agent not found" unless $agent;
+ $agentname = $agent->agent;
+ $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
+ }
+
+ # my ( $location_sql, @location_param ) = FS::cust_pkg->location_sql;
+ # $where .= " AND $location_sql";
+ #my @taxparam = ( 'itemdesc', @location_param );
+ # now something along the lines of geocode matching ?
+ #$where .= FS::cust_pkg->_location_sql_where('cust_tax_location');;
+ my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
+
+ my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
+
+ #false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
+ #to FS::Report or FS::Record or who the fuck knows where)
+ my $scalar_sql = sub {
+ my( $r, $param, $sql ) = @_;
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute( map $r->$_(), @$param )
+ or die "Unexpected error executing statement $sql: ". $sth->errstr;
+ $sth->fetchrow_arrayref->[0] || 0;
+ };
+
+ my $tax = 0;
+ my $credit = 0;
+ my %taxes = ();
+ my %basetaxes = ();
+ foreach my $t (qsearch({ table => 'cust_bill_pkg',
+ select => $select,
+ hashref => { pkgpart => 0 },
+ addl_from => $addl_from,
+ extra_sql => $where,
+ })
+ )
+ {
+ my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
+ my $label = join('~', map { $t->$_ } @params);
+ $label = 'Tax'. $label if $label =~ /^~/;
+ unless ( exists( $taxes{$label} ) ) {
+ my ($baselabel, @trash) = split /~/, $label;
+
+ $taxes{$label}->{'label'} = join(', ', split(/~/, $label) );
+ $taxes{$label}->{'url_param'} =
+ join(';', map { "$_=". uri_escape($t->$_) } @params);
+
+ my $taxwhere = "FROM cust_bill_pkg $addl_from $where AND payby != 'COMP' ".
+ "AND ". join( ' AND ', map { "( $_ = ? OR ? = '' AND $_ IS NULL)" } @taxparam );
+
+ my $sql = "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur) ".
+ " $taxwhere AND cust_bill_pkg.pkgnum = 0";
+
+ my $x = &{$scalar_sql}($t, [ map { $_, $_ } @params ], $sql );
+ $tax += $x;
+ $taxes{$label}->{'tax'} += $x;
+
+ my $creditfrom = " JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum) ";
+ my $creditwhere = "FROM cust_bill_pkg $addl_from $creditfrom $where ".
+ "AND payby != 'COMP' ".
+ "AND ". join( ' AND ', map { "( $_ = ? OR ? = '' AND $_ IS NULL)" } @taxparam );
+
+ $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
+ " $creditwhere AND cust_bill_pkg.pkgnum = 0";
+
+ my $y = &{$scalar_sql}($t, [ map { $_, $_ } @params ], $sql );
+ $credit += $y;
+ $taxes{$label}->{'credit'} += $y;
+
+ unless ( exists( $taxes{$baselabel} ) ) {
+
+ $basetaxes{$baselabel}->{'label'} = $baselabel;
+ $basetaxes{$baselabel}->{'url_param'} = "itemdesc=$baselabel";
+ $basetaxes{$baselabel}->{'base'} = 1;
+
+ }
+
+ $basetaxes{$baselabel}->{'tax'} += $x;
+ $basetaxes{$baselabel}->{'credit'} += $y;
+
+ }
+
+ # calculate customer-exemption for this tax
+ # calculate package-exemption for this tax
+ # calculate monthly exemption (texas tax) for this tax
+ # count up all the cust_tax_exempt_pkg records associated with
+ # the actual line items.
+ }
+
+
+ #ordering
+ my @taxes = ();
+
+ foreach my $tax ( sort { $a cmp $b } keys %taxes ) {
+ my ($base, @trash) = split '~', $tax;
+ my $basetax = delete( $basetaxes{$base} );
+ if ($basetax) {
+ if ( $basetax->{tax} == $taxes{$tax}->{tax} ) {
+ $taxes{$tax}->{base} = 1;
+ } else {
+ push @taxes, $basetax;
+ }
+ }
+ push @taxes, $taxes{$tax};
+ }
+
+ push @taxes, {
+ 'label' => 'Total',
+ 'url_param' => '',
+ 'tax' => $tax,
+ 'credit' => $credit,
+ 'base' => 1,
+ };
+
+
+ my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
+ $dateagentlink .= ';agentnum='. $args{agentnum}
+ if length($agentname);
+ my $baselink = $args{p}. "search/cust_bill_pkg.cgi?$dateagentlink";
+
+
+ print $report <<EOF;
+
+ <% include("/elements/header.html", "$agentname Tax Report - ".
+ ( $args{beginning}
+ ? time2str('%h %o %Y ', $args{beginning} )
+ : ''
+ ).
+ 'through '.
+ ( $args{ending} == 4294967295
+ ? 'now'
+ : time2str('%h %o %Y', $args{ending} )
+ )
+ )
+ %>
+
+ <% include('/elements/table-grid.html') %>
+
+ <TR>
+ <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
+ <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
+ <TH CLASS="grid" BGCOLOR="#cccccc">Tax collected</TH>
+ <TH CLASS="grid" BGCOLOR="#cccccc"> </TH>
+ <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
+ <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
+ </TR>
+EOF
+
+ my $bgcolor1 = '#eeeeee';
+ my $bgcolor2 = '#ffffff';
+ my $bgcolor = '';
+
+ foreach my $tax ( @taxes ) {
+
+ if ( $bgcolor eq $bgcolor1 ) {
+ $bgcolor = $bgcolor2;
+ } else {
+ $bgcolor = $bgcolor1;
+ }
+
+ my $link = '';
+ if ( $tax->{'label'} ne 'Total' ) {
+ $link = ';'. $tax->{'url_param'};
+ }
+
+ print $report <<EOF;
+ <TR>
+ <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"><% '$tax->{label}' %></TD>
+ <% $tax->{base} ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
+ <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
+ <A HREF="<% '$baselink$link' %>;istax=1"><% '$money_char' %><% sprintf('%.2f', $tax->{'tax'} ) %></A>
+ </TD>
+ <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
+ <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
+ <% $tax->{base} ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
+ <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
+ <A HREF="<% '$baselink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
+ </TD>
+ <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
+ </TR>
+EOF
+ }
+
+ print $report <<EOF;
+ </TABLE>
+
+ </BODY>
+ </HTML>
+EOF
+
+ my $reportname = $report->filename;
+ close $report;
+
+ my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
+ $reportname =~ s/^$dropstring//;
+
+ my $reporturl = $args{rooturl}. "/misc/queued_report?report=$reportname";
+ die "<a href=$reporturl>view</a>\n";
+
+}
+
+
+