4 use vars qw( @ISA $DEBUG $me
5 %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
6 %tax_passtypes %GetInfoType $keep_cch_files );
9 use DateTime::Format::Strptime;
10 use Storable qw( thaw nfreeze );
19 use DBIx::DBSchema::Table;
20 use DBIx::DBSchema::Column;
21 use FS::Record qw( qsearch qsearchs dbh dbdef );
24 use FS::cust_bill_pkg;
25 use FS::cust_tax_location;
26 use FS::tax_rate_location;
27 use FS::part_pkg_taxrate;
28 use FS::part_pkg_taxproduct;
30 use FS::Misc qw( csv_from_fixed );
34 @ISA = qw( FS::Record );
37 $me = '[FS::tax_rate]';
42 FS::tax_rate - Object methods for tax_rate objects
48 $record = new FS::tax_rate \%hash;
49 $record = new FS::tax_rate { 'column' => 'value' };
51 $error = $record->insert;
53 $error = $new_record->replace($old_record);
55 $error = $record->delete;
57 $error = $record->check;
61 An FS::tax_rate object represents a tax rate, defined by locale.
62 FS::tax_rate inherits from FS::Record. The following fields are
69 primary key (assigned automatically for new tax rates)
73 a geographic location code provided by a tax data vendor
81 a location code provided by a tax authority
85 a foreign key into FS::tax_class - the type of tax
86 referenced but FS::part_pkg_taxrate
89 the time after which the tax applies
97 second bracket percentage
101 the amount to which the tax applies (first bracket)
105 a cap on the amount of tax if a cap exists
109 percentage on out of jurisdiction purchases
113 second bracket percentage on out of jurisdiction purchases
117 one of the values in %tax_unittypes
121 amount of tax per unit
125 second bracket amount of tax per unit
129 the number of units to which the fee applies (first bracket)
133 the most units to which fees apply (first and second brackets)
137 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
141 if defined, printed on invoices instead of "Tax"
145 a value from %tax_authorities
149 a value from %tax_basetypes indicating the tax basis
153 a value from %tax_passtypes indicating how the tax should displayed to the customer
157 'Y', 'N', or blank indicating the tax can be passed to the customer
161 if 'Y', this tax does not apply to setup fees
165 if 'Y', this tax does not apply to recurring fees
169 if 'Y', has been manually edited
179 Creates a new tax rate. To add the tax rate to the database, see L<"insert">.
183 sub table { 'tax_rate'; }
187 Adds this tax rate to the database. If there is an error, returns the error,
188 otherwise returns false.
192 Deletes this tax rate from the database. If there is an error, returns the
193 error, otherwise returns false.
195 =item replace OLD_RECORD
197 Replaces the OLD_RECORD with this one in the database. If there is an error,
198 returns the error, otherwise returns false.
202 Checks all fields to make sure this is a valid tax rate. If there is an error,
203 returns the error, otherwise returns false. Called by the insert and replace
211 foreach (qw( taxbase taxmax )) {
212 $self->$_(0) unless $self->$_;
215 $self->ut_numbern('taxnum')
216 || $self->ut_text('geocode')
217 || $self->ut_textn('data_vendor')
218 || $self->ut_textn('location')
219 || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
220 || $self->ut_snumbern('effective_date')
221 || $self->ut_float('tax')
222 || $self->ut_floatn('excessrate')
223 || $self->ut_money('taxbase')
224 || $self->ut_money('taxmax')
225 || $self->ut_floatn('usetax')
226 || $self->ut_floatn('useexcessrate')
227 || $self->ut_numbern('unittype')
228 || $self->ut_floatn('fee')
229 || $self->ut_floatn('excessfee')
230 || $self->ut_floatn('feemax')
231 || $self->ut_numbern('maxtype')
232 || $self->ut_textn('taxname')
233 || $self->ut_numbern('taxauth')
234 || $self->ut_numbern('basetype')
235 || $self->ut_numbern('passtype')
236 || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
237 || $self->ut_enum('setuptax', [ '', 'Y' ] )
238 || $self->ut_enum('recurtax', [ '', 'Y' ] )
239 || $self->ut_enum('inoutcity', [ '', 'I', 'O' ] )
240 || $self->ut_enum('inoutlocal', [ '', 'I', 'O' ] )
241 || $self->ut_enum('manual', [ '', 'Y' ] )
242 || $self->ut_enum('disabled', [ '', 'Y' ] )
243 || $self->SUPER::check
248 =item taxclass_description
250 Returns the human understandable value associated with the related
255 sub taxclass_description {
257 my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
258 $tax_class ? $tax_class->description : '';
263 Returns the human understandable value associated with the unittype column
267 %tax_unittypes = ( '0' => 'access line',
274 $tax_unittypes{$self->unittype};
279 Returns the human understandable value associated with the maxtype column
283 %tax_maxtypes = ( '0' => 'receipts per invoice',
284 '1' => 'receipts per item',
285 '2' => 'total utility charges per utility tax year',
286 '3' => 'total charges per utility tax year',
287 '4' => 'receipts per access line',
288 '9' => 'monthly receipts per location',
293 $tax_maxtypes{$self->maxtype};
298 Returns the human understandable value associated with the basetype column
302 %tax_basetypes = ( '0' => 'sale price',
303 '1' => 'gross receipts',
304 '2' => 'sales taxable telecom revenue',
305 '3' => 'minutes carried',
306 '4' => 'minutes billed',
307 '5' => 'gross operating revenue',
308 '6' => 'access line',
310 '8' => 'gross revenue',
311 '9' => 'portion gross receipts attributable to interstate service',
312 '10' => 'access line',
313 '11' => 'gross profits',
314 '12' => 'tariff rate',
316 '15' => 'prior year gross receipts',
321 $tax_basetypes{$self->basetype};
326 Returns the human understandable value associated with the taxauth column
330 %tax_authorities = ( '0' => 'federal',
335 '5' => 'county administered by state',
336 '6' => 'city administered by state',
337 '7' => 'city administered by county',
338 '8' => 'local administered by state',
339 '9' => 'local administered by county',
344 $tax_authorities{$self->taxauth};
349 Returns the human understandable value associated with the passtype column
353 %tax_passtypes = ( '0' => 'separate tax line',
354 '1' => 'separate surcharge line',
355 '2' => 'surcharge not separated',
356 '3' => 'included in base rate',
361 $tax_passtypes{$self->passtype};
364 =item taxline TAXABLES, [ OPTIONSHASH ]
366 Returns a listref of a name and an amount of tax calculated for the list
367 of packages/amounts referenced by TAXABLES. If an error occurs, a message
368 is returned as a scalar.
378 if (ref($_[0]) eq 'ARRAY') {
383 #exemptions would be broken in this case
386 my $name = $self->taxname;
387 $name = 'Other surcharges'
388 if ($self->passtype == 2);
391 if ( $self->disabled ) { # we always know how to handle disabled taxes
398 my $taxable_charged = 0;
399 my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
402 warn "calculating taxes for ". $self->taxnum. " on ".
403 join (",", map { $_->pkgnum } @cust_bill_pkg)
406 if ($self->passflag eq 'N') {
407 # return "fatal: can't (yet) handle taxes not passed to the customer";
408 # until someone needs to track these in freeside
415 my $maxtype = $self->maxtype || 0;
416 if ($maxtype != 0 && $maxtype != 9) {
417 return $self->_fatal_or_null( 'tax with "'.
418 $self->maxtype_name. '" threshold'
424 $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' );
428 # we treat gross revenue as gross receipts and expect the tax data
429 # to DTRT (i.e. tax on tax rules)
430 if ($self->basetype != 0 && $self->basetype != 1 &&
431 $self->basetype != 5 && $self->basetype != 6 &&
432 $self->basetype != 7 && $self->basetype != 8 &&
433 $self->basetype != 14
436 $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
439 unless ($self->setuptax =~ /^Y$/i) {
440 $taxable_charged += $_->setup foreach @cust_bill_pkg;
442 unless ($self->recurtax =~ /^Y$/i) {
443 $taxable_charged += $_->recur foreach @cust_bill_pkg;
446 my $taxable_units = 0;
447 unless ($self->recurtax =~ /^Y$/i) {
449 if (( $self->unittype || 0 ) == 0) { #access line
451 foreach (@cust_bill_pkg) {
452 $taxable_units += $_->units
453 unless $seen{$_->pkgnum}++;
456 } elsif ($self->unittype == 1) { #minute
457 return $self->_fatal_or_null( 'fee with minute unit type' );
459 } elsif ($self->unittype == 2) { #account
461 my $conf = new FS::Conf;
462 if ( $conf->exists('tax-pkg_address') ) {
463 #number of distinct locations
465 foreach (@cust_bill_pkg) {
467 unless $seen{$_->cust_pkg->locationnum}++;
474 return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
480 # XXX insert exemption handling here
482 # the tax or fee is applied to taxbase or feebase and then
483 # the excessrate or excess fee is applied to taxmax or feemax
486 $amount += $taxable_charged * $self->tax;
487 $amount += $taxable_units * $self->fee;
489 warn "calculated taxes as [ $name, $amount ]\n"
500 my ($self, $error) = @_;
502 my $conf = new FS::Conf;
504 $error = "can't yet handle ". $error;
505 my $name = $self->taxname;
506 $name = 'Other surcharges'
507 if ($self->passtype == 2);
509 if ($conf->exists('ignore_incalculable_taxes')) {
510 warn "WARNING: $error; billing anyway per ignore_incalculable_taxes conf\n";
511 return { name => $name, amount => 0 };
513 return "fatal: $error";
517 =item tax_on_tax CUST_MAIN
519 Returns a list of taxes which are candidates for taxing taxes for the
520 given customer (see L<FS::cust_main>)
528 my $cust_main = shift;
530 warn "looking up taxes on tax ". $self->taxnum. " for customer ".
534 my $geocode = $cust_main->geocode($self->data_vendor);
538 my $extra_sql = ' AND ('.
539 join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
544 my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
545 my $select = 'DISTINCT ON(taxclassnum) *';
547 # should qsearch preface columns with the table to facilitate joins?
548 my @taxclassnums = map { $_->taxclassnum }
549 qsearch( { 'table' => 'part_pkg_taxrate',
551 'hashref' => { 'data_vendor' => $self->data_vendor,
552 'taxclassnumtaxed' => $self->taxclassnum,
554 'extra_sql' => $extra_sql,
555 'order_by' => $order_by,
558 return () unless @taxclassnums;
561 "AND (". join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
563 qsearch({ 'table' => 'tax_rate',
564 'hashref' => { 'geocode' => $geocode, },
565 'extra_sql' => $extra_sql,
570 =item tax_rate_location
572 Returns an object representing the location associated with this tax
573 (see L<FS::tax_rate_location>)
577 sub tax_rate_location {
580 qsearchs({ 'table' => 'tax_rate_location',
581 'hashref' => { 'data_vendor' => $self->data_vendor,
582 'geocode' => $self->geocode,
586 new FS::tax_rate_location;
600 sub _progressbar_foo {
605 my ($param, $job) = @_;
607 my $fh = $param->{filehandle};
608 my $format = $param->{'format'};
616 my @column_lengths = ();
617 my @column_callbacks = ();
618 if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
619 $format =~ s/-fixed//;
620 my $date_format = sub { my $r='';
621 /^(\d{4})(\d{2})(\d{2})$/ && ($r="$3/$2/$1");
624 my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
625 push @column_lengths, qw( 10 1 1 8 8 5 8 8 8 1 2 2 30 8 8 10 2 8 2 1 2 2 );
626 push @column_lengths, 1 if $format eq 'cch-update';
627 push @column_callbacks, $trim foreach (@column_lengths); # 5, 6, 15, 17 esp
628 $column_callbacks[8] = $date_format;
632 my ( $count, $last, $min_sec ) = _progressbar_foo();
633 if ( $job || scalar(@column_callbacks) ) {
635 csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
636 return $error if $error;
640 if ( $format eq 'cch' || $format eq 'cch-update' ) {
641 #false laziness w/below (sub _perform_cch_diff)
642 @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
643 excessrate effective_date taxauth taxtype taxcat taxname
644 usetax useexcessrate fee unittype feemax maxtype passflag
646 push @fields, 'actionflag' if $format eq 'cch-update';
651 $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
652 $hash->{'data_vendor'} ='cch';
653 my $parser = new DateTime::Format::Strptime( pattern => "%m/%d/%Y",
654 time_zone => 'floating',
656 my $dt = $parser->parse_datetime( $hash->{'effective_date'} );
657 $hash->{'effective_date'} = $dt ? $dt->epoch : '';
659 $hash->{$_} =~ s/\s//g foreach qw( inoutcity inoutlocal ) ;
660 $hash->{$_} = sprintf("%.2f", $hash->{$_}) foreach qw( taxbase taxmax );
663 join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
665 my %tax_class = ( 'data_vendor' => 'cch',
666 'taxclass' => $taxclassid,
669 my $tax_class = qsearchs( 'tax_class', \%tax_class );
670 return "Error updating tax rate: no tax class $taxclassid"
673 $hash->{'taxclassnum'} = $tax_class->taxclassnum;
675 foreach (qw( taxtype taxcat )) {
679 my %passflagmap = ( '0' => '',
683 $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
684 if exists $passflagmap{$hash->{'passflag'}};
686 foreach (keys %$hash) {
687 $hash->{$_} = substr($hash->{$_}, 0, 80)
688 if length($hash->{$_}) > 80;
691 my $actionflag = delete($hash->{'actionflag'});
693 $hash->{'taxname'} =~ s/`/'/g;
694 $hash->{'taxname'} =~ s|\\|/|g;
696 return '' if $format eq 'cch'; # but not cch-update
698 if ($actionflag eq 'I') {
699 $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
700 }elsif ($actionflag eq 'D') {
701 $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
703 return "Unexpected action flag: ". $hash->{'actionflag'};
706 delete($hash->{$_}) for keys %$hash;
712 } elsif ( $format eq 'extended' ) {
713 die "unimplemented\n";
717 die "unknown format $format";
720 my $csv = new Text::CSV_XS;
724 local $SIG{HUP} = 'IGNORE';
725 local $SIG{INT} = 'IGNORE';
726 local $SIG{QUIT} = 'IGNORE';
727 local $SIG{TERM} = 'IGNORE';
728 local $SIG{TSTP} = 'IGNORE';
729 local $SIG{PIPE} = 'IGNORE';
731 my $oldAutoCommit = $FS::UID::AutoCommit;
732 local $FS::UID::AutoCommit = 0;
735 while ( defined($line=<$fh>) ) {
736 $csv->parse($line) or do {
737 $dbh->rollback if $oldAutoCommit;
738 return "can't parse: ". $csv->error_input();
741 if ( $job ) { # progress bar
742 if ( time - $min_sec > $last ) {
743 my $error = $job->update_statustext(
744 int( 100 * $imported / $count ). ",Importing tax rates"
747 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
754 my @columns = $csv->fields();
756 my %tax_rate = ( 'data_vendor' => $format );
757 foreach my $field ( @fields ) {
758 $tax_rate{$field} = shift @columns;
761 if ( scalar( @columns ) ) {
762 $dbh->rollback if $oldAutoCommit;
763 return "Unexpected trailing columns in line (wrong format?) importing tax_rate: $line";
766 my $error = &{$hook}(\%tax_rate);
768 $dbh->rollback if $oldAutoCommit;
772 if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
774 my $tax_rate = new FS::tax_rate( \%tax_rate );
775 $error = $tax_rate->insert;
778 $dbh->rollback if $oldAutoCommit;
779 return "can't insert tax_rate for $line: $error";
788 my @replace = grep { exists($delete{$_}) } keys %insert;
790 if ( $job ) { # progress bar
791 if ( time - $min_sec > $last ) {
792 my $error = $job->update_statustext(
793 int( 100 * $imported / $count ). ",Importing tax rates"
796 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
803 my $old = qsearchs( 'tax_rate', $delete{$_} );
807 my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => '' });
808 $new->taxnum($old->taxnum);
809 my $error = $new->replace($old);
812 $dbh->rollback if $oldAutoCommit;
813 my $hashref = $insert{$_};
814 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
815 return "can't replace tax_rate for $line: $error";
822 $old = delete $delete{$_};
823 warn "WARNING: can't find tax_rate to replace (inserting instead and continuing) for: ".
824 #join(" ", map { "$_ => ". $old->{$_} } @fields);
825 join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
831 for (grep { !exists($delete{$_}) } keys %insert) {
832 if ( $job ) { # progress bar
833 if ( time - $min_sec > $last ) {
834 my $error = $job->update_statustext(
835 int( 100 * $imported / $count ). ",Importing tax rates"
838 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
845 my $tax_rate = new FS::tax_rate( $insert{$_} );
846 my $error = $tax_rate->insert;
849 $dbh->rollback if $oldAutoCommit;
850 my $hashref = $insert{$_};
851 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
852 return "can't insert tax_rate for $line: $error";
858 for (grep { !exists($insert{$_}) } keys %delete) {
859 if ( $job ) { # progress bar
860 if ( time - $min_sec > $last ) {
861 my $error = $job->update_statustext(
862 int( 100 * $imported / $count ). ",Importing tax rates"
865 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
872 my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
874 $dbh->rollback if $oldAutoCommit;
875 $tax_rate = $delete{$_};
876 return "can't find tax_rate to delete for: ".
877 #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
878 join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
880 my $error = $tax_rate->delete;
883 $dbh->rollback if $oldAutoCommit;
884 my $hashref = $delete{$_};
885 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
886 return "can't delete tax_rate for $line: $error";
892 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
894 return "Empty file!" unless ($imported || $format eq 'cch-update');
900 =item process_batch_import
902 Load a batch import as a queued JSRPC job
906 sub process_batch_import {
909 my $oldAutoCommit = $FS::UID::AutoCommit;
910 local $FS::UID::AutoCommit = 0;
913 my $param = thaw(decode_base64(shift));
914 my $args = '$job, encode_base64( nfreeze( $param ) )';
916 my $method = '_perform_batch_import';
917 if ( $param->{reload} ) {
918 $method = 'process_batch_reload';
921 eval "$method($args);";
923 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
928 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
931 sub _perform_batch_import {
934 my $param = thaw(decode_base64(shift));
935 my $format = $param->{'format'}; #well... this is all cch specific
937 my $files = $param->{'uploaded_files'}
938 or die "No files provided.";
940 my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
943 if ( $format eq 'cch' || $format eq 'cch-fixed'
944 || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
947 my $oldAutoCommit = $FS::UID::AutoCommit;
948 local $FS::UID::AutoCommit = 0;
951 my @insert_list = ();
952 my @delete_list = ();
953 my @predelete_list = ();
956 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
958 my @list = ( 'GEOCODE', \&FS::tax_rate_location::batch_import,
959 'CODE', \&FS::tax_class::batch_import,
960 'PLUS4', \&FS::cust_tax_location::batch_import,
961 'ZIP', \&FS::cust_tax_location::batch_import,
962 'TXMATRIX', \&FS::part_pkg_taxrate::batch_import,
963 'DETAIL', \&FS::tax_rate::batch_import,
965 while( scalar(@list) ) {
966 my ( $name, $import_sub ) = splice( @list, 0, 2 );
967 my $file = lc($name). 'file';
969 unless ($files{$file}) {
970 #$error = "No $name supplied";
973 next if $name eq 'DETAIL' && $format =~ /update/;
975 my $filename = "$dir/". $files{$file};
977 if ( $format =~ /update/ ) {
979 ( $error, $insertname, $deletename ) =
980 _perform_cch_insert_delete_split( $name, $filename, $dir, $format )
984 unlink $filename or warn "Can't delete $filename: $!"
985 unless $keep_cch_files;
986 push @insert_list, $name, $insertname, $import_sub, $format;
987 if ( $name eq 'GEOCODE' || $name eq 'CODE' ) { #handle this whole ordering issue better
988 unshift @predelete_list, $name, $deletename, $import_sub, $format;
990 unshift @delete_list, $name, $deletename, $import_sub, $format;
995 push @insert_list, $name, $filename, $import_sub, $format;
1002 'DETAIL', "$dir/".$files{detailfile}, \&FS::tax_rate::batch_import, $format
1003 if $format =~ /update/;
1005 my %addl_param = ();
1006 if ( $param->{'delete_only'} ) {
1007 $addl_param{'delete_only'} = $param->{'delete_only'};
1011 $error ||= _perform_cch_tax_import( $job,
1012 [ @predelete_list ],
1019 @list = ( @predelete_list, @insert_list, @delete_list );
1020 while( !$keep_cch_files && scalar(@list) ) {
1021 my ( undef, $file, undef, undef ) = splice( @list, 0, 4 );
1022 unlink $file or warn "Can't delete $file: $!";
1026 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1029 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1033 die "Unknown format: $format";
1039 sub _perform_cch_tax_import {
1040 my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
1044 foreach my $list ($predelete_list, $insert_list, $delete_list) {
1045 while( scalar(@$list) ) {
1046 my ( $name, $file, $method, $format ) = splice( @$list, 0, 4 );
1047 my $fmt = "$format-update";
1048 $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
1049 open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
1050 my $param = { 'filehandle' => $fh,
1054 $error ||= &{$method}($param, $job);
1062 sub _perform_cch_insert_delete_split {
1063 my ($name, $filename, $dir, $format) = @_;
1067 open my $fh, "< $filename"
1068 or $error ||= "Can't open $name file $filename: $!";
1070 my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
1073 ) or die "can't open temp file: $!\n";
1074 my $insertname = $ifh->filename;
1076 my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
1079 ) or die "can't open temp file: $!\n";
1080 my $deletename = $dfh->filename;
1082 my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
1083 my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
1086 $handle = $ifh if $_ =~ /$insert_pattern/;
1087 $handle = $dfh if $_ =~ /$delete_pattern/;
1089 $error = "bad input line: $_" unless $handle;
1098 return ($error, $insertname, $deletename);
1101 sub _perform_cch_diff {
1102 my ($name, $newdir, $olddir) = @_;
1107 open my $oldcsvfh, "$olddir/$name.txt"
1108 or die "failed to open $olddir/$name.txt: $!\n";
1110 while(<$oldcsvfh>) {
1117 open my $newcsvfh, "$newdir/$name.txt"
1118 or die "failed to open $newdir/$name.txt: $!\n";
1120 my $dfh = new File::Temp( TEMPLATE => "$name.diff.XXXXXXXX",
1123 ) or die "can't open temp file: $!\n";
1124 my $diffname = $dfh->filename;
1126 while(<$newcsvfh>) {
1128 if (exists($oldlines{$_})) {
1131 print $dfh $_, ',"I"', "\n";
1136 #false laziness w/above (sub batch_import)
1137 my @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
1138 excessrate effective_date taxauth taxtype taxcat taxname
1139 usetax useexcessrate fee unittype feemax maxtype passflag
1140 passtype basetype );
1141 my $numfields = scalar(@fields);
1143 my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1145 for my $line (grep $oldlines{$_}, keys %oldlines) {
1147 $csv->parse($line) or do {
1148 #$dbh->rollback if $oldAutoCommit;
1149 die "can't parse: ". $csv->error_input();
1151 my @columns = $csv->fields();
1153 $csv->combine( splice(@columns, 0, $numfields) );
1155 print $dfh $csv->string, ',"D"', "\n";
1163 sub _cch_fetch_and_unzip {
1164 my ( $job, $urls, $secret, $dir ) = @_;
1166 my $ua = new LWP::UserAgent;
1167 foreach my $url (split ',', $urls) {
1168 my @name = split '/', $url; #somewhat restrictive
1169 my $name = pop @name;
1170 $name =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1173 open my $taxfh, ">$dir/$name" or die "Can't open $dir/$name: $!\n";
1175 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1176 my $res = $ua->request(
1177 new HTTP::Request( GET => $url ),
1179 print $taxfh $_[0] or die "Can't write to $dir/$name: $!\n";
1180 my $content_length = $_[1]->content_length;
1181 $imported += length($_[0]);
1182 if ( time - $min_sec > $last ) {
1183 my $error = $job->update_statustext(
1184 ($content_length ? int(100 * $imported/$content_length) : 0 ).
1185 ",Downloading data from CCH"
1187 die $error if $error;
1192 die "download of $url failed: ". $res->status_line
1193 unless $res->is_success;
1196 my $error = $job->update_statustext( "0,Unpacking data" );
1197 die $error if $error;
1198 $secret =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1200 system('unzip', "-P", $secret, "-d", "$dir", "$dir/$name") == 0
1201 or die "unzip -P $secret -d $dir $dir/$name failed";
1202 #unlink "$dir/$name";
1206 sub _cch_extract_csv_from_dbf {
1207 my ( $job, $dir, $name ) = @_;
1212 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1213 my $error = $job->update_statustext( "0,Unpacking $name" );
1214 die $error if $error;
1215 warn "opening $dir.new/$name.dbf\n" if $DEBUG;
1216 my $table = new XBase 'name' => "$dir.new/$name.dbf";
1217 die "failed to access $dir.new/$name.dbf: ". XBase->errstr
1218 unless defined($table);
1219 my $count = $table->last_record; # approximately;
1220 open my $csvfh, ">$dir.new/$name.txt"
1221 or die "failed to open $dir.new/$name.txt: $!\n";
1223 my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1224 my @fields = $table->field_names;
1225 my $cursor = $table->prepare_select;
1227 sub { my $date = shift;
1228 $date =~ /^(\d{4})(\d{2})(\d{2})$/ && ($date = "$2/$3/$1");
1231 while (my $row = $cursor->fetch_hashref) {
1232 $csv->combine( map { my $type = $table->field_type($_);
1234 &{$format_date}($row->{$_}) ;
1235 } elsif ($type eq 'N' && $row->{$_} =~ /e-/i ) {
1236 sprintf('%.8f', $row->{$_}); #db row is numeric(14,8)
1243 print $csvfh $csv->string, "\n";
1245 if ( time - $min_sec > $last ) {
1246 my $error = $job->update_statustext(
1247 int(100 * $imported/$count). ",Unpacking $name"
1249 die $error if $error;
1257 sub _remember_disabled_taxes {
1258 my ( $job, $format, $disabled_tax_rate ) = @_;
1262 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1264 my @items = qsearch( { table => 'tax_rate',
1265 hashref => { disabled => 'Y',
1266 data_vendor => $format,
1268 select => 'geocode, taxclassnum',
1271 my $count = scalar(@items);
1272 foreach my $tax_rate ( @items ) {
1273 if ( time - $min_sec > $last ) {
1274 $job->update_statustext(
1275 int( 100 * $imported / $count ). ",Remembering disabled taxes"
1281 qsearchs( 'tax_class', { taxclassnum => $tax_rate->taxclassnum } );
1282 unless ( $tax_class ) {
1283 warn "failed to find tax_class ". $tax_rate->taxclassnum;
1286 $disabled_tax_rate->{$tax_rate->geocode. ':'. $tax_class->taxclass} = 1;
1290 sub _remember_tax_products {
1291 my ( $job, $format, $taxproduct ) = @_;
1293 # XXX FIXME this loop only works when cch is the only data provider
1295 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1297 my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
1298 "0 < ( SELECT count(*) from part_pkg_option WHERE ".
1299 " part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
1300 " optionname LIKE 'usage_taxproductnum_%' AND ".
1301 " optionvalue != '' )";
1302 my @items = qsearch( { table => 'part_pkg',
1303 select => 'DISTINCT pkgpart,taxproductnum',
1305 extra_sql => $extra_sql,
1308 my $count = scalar(@items);
1309 foreach my $part_pkg ( @items ) {
1310 if ( time - $min_sec > $last ) {
1311 $job->update_statustext(
1312 int( 100 * $imported / $count ). ",Remembering tax products"
1317 warn "working with package part ". $part_pkg->pkgpart.
1318 "which has a taxproductnum of ". $part_pkg->taxproductnum. "\n" if $DEBUG;
1319 my $part_pkg_taxproduct = $part_pkg->taxproduct('');
1320 $taxproduct->{$part_pkg->pkgpart}->{''} = $part_pkg_taxproduct->taxproduct
1321 if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1323 foreach my $option ( $part_pkg->part_pkg_option ) {
1324 next unless $option->optionname =~ /^usage_taxproductnum_(\w+)$/;
1327 $part_pkg_taxproduct = $part_pkg->taxproduct($class);
1328 $taxproduct->{$part_pkg->pkgpart}->{$class} =
1329 $part_pkg_taxproduct->taxproduct
1330 if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1335 sub _restore_remembered_tax_products {
1336 my ( $job, $format, $taxproduct ) = @_;
1340 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1341 my $count = scalar(keys %$taxproduct);
1342 foreach my $pkgpart ( keys %$taxproduct ) {
1343 warn "restoring taxproductnums on pkgpart $pkgpart\n" if $DEBUG;
1344 if ( time - $min_sec > $last ) {
1345 $job->update_statustext(
1346 int( 100 * $imported / $count ). ",Restoring tax products"
1352 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkgpart } );
1353 unless ( $part_pkg ) {
1354 return "somehow failed to find part_pkg with pkgpart $pkgpart!\n";
1357 my %options = $part_pkg->options;
1358 my %pkg_svc = map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc;
1359 my $primary_svc = $part_pkg->svcpart;
1360 my $new = new FS::part_pkg { $part_pkg->hash };
1362 foreach my $class ( keys %{ $taxproduct->{$pkgpart} } ) {
1363 warn "working with class '$class'\n" if $DEBUG;
1364 my $part_pkg_taxproduct =
1365 qsearchs( 'part_pkg_taxproduct',
1366 { taxproduct => $taxproduct->{$pkgpart}->{$class},
1367 data_vendor => $format,
1371 unless ( $part_pkg_taxproduct ) {
1372 return "failed to find part_pkg_taxproduct (".
1373 $taxproduct->{$pkgpart}->{$class}. ") for pkgpart $pkgpart\n";
1376 if ( $class eq '' ) {
1377 $new->taxproductnum($part_pkg_taxproduct->taxproductnum);
1381 $options{"usage_taxproductnum_$class"} =
1382 $part_pkg_taxproduct->taxproductnum;
1386 my $error = $new->replace( $part_pkg,
1387 'pkg_svc' => \%pkg_svc,
1388 'primary_svc' => $primary_svc,
1389 'options' => \%options,
1392 return $error if $error;
1399 sub _restore_remembered_disabled_taxes {
1400 my ( $job, $format, $disabled_tax_rate ) = @_;
1402 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1403 my $count = scalar(keys %$disabled_tax_rate);
1404 foreach my $key (keys %$disabled_tax_rate) {
1405 if ( time - $min_sec > $last ) {
1406 $job->update_statustext(
1407 int( 100 * $imported / $count ). ",Disabling tax rates"
1412 my ($geocode,$taxclass) = split /:/, $key, 2;
1413 my @tax_class = qsearch( 'tax_class', { data_vendor => $format,
1414 taxclass => $taxclass,
1416 return "found multiple tax_class records for format $format class $taxclass"
1417 if scalar(@tax_class) > 1;
1419 unless (scalar(@tax_class)) {
1420 warn "no tax_class for format $format class $taxclass\n";
1425 qsearch('tax_rate', { data_vendor => $format,
1426 geocode => $geocode,
1427 taxclassnum => $tax_class[0]->taxclassnum,
1431 if (scalar(@tax_rate) > 1) {
1432 return "found multiple tax_rate records for format $format geocode ".
1433 "$geocode and taxclass $taxclass ( taxclassnum ".
1434 $tax_class[0]->taxclassnum. " )";
1437 if (scalar(@tax_rate)) {
1438 $tax_rate[0]->disabled('Y');
1439 my $error = $tax_rate[0]->replace;
1440 return $error if $error;
1445 sub _remove_old_tax_data {
1446 my ( $job, $format ) = @_;
1449 my $error = $job->update_statustext( "0,Removing old tax data" );
1450 die $error if $error;
1452 my $sql = "UPDATE public.tax_rate_location SET disabled='Y' ".
1453 "WHERE data_vendor = ". $dbh->quote($format);
1454 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1457 tax_rate part_pkg_taxrate part_pkg_taxproduct tax_class cust_tax_location
1459 foreach my $table ( @table ) {
1460 $sql = "DELETE FROM public.$table WHERE data_vendor = ".
1461 $dbh->quote($format);
1462 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1465 if ( $format eq 'cch' ) {
1466 $sql = "DELETE FROM public.cust_tax_location WHERE data_vendor = ".
1467 $dbh->quote("$format-zip");
1468 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1474 sub _create_temporary_tables {
1475 my ( $job, $format ) = @_;
1478 my $error = $job->update_statustext( "0,Creating temporary tables" );
1479 die $error if $error;
1481 my @table = qw( tax_rate
1488 foreach my $table ( @table ) {
1490 "CREATE TEMPORARY TABLE $table ( LIKE $table INCLUDING DEFAULTS )";
1491 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1497 sub _copy_from_temp {
1498 my ( $job, $format ) = @_;
1501 my $error = $job->update_statustext( "0,Making permanent" );
1502 die $error if $error;
1504 my @table = qw( tax_rate
1511 foreach my $table ( @table ) {
1513 "INSERT INTO public.$table SELECT * from $table";
1514 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1520 =item process_download_and_reload
1522 Download and process a tax update as a queued JSRPC job after wiping the
1523 existing wipable tax data.
1527 sub process_download_and_reload {
1528 _process_reload('process_download_and_update', @_);
1532 =item process_batch_reload
1534 Load and process a tax update from the provided files as a queued JSRPC job
1535 after wiping the existing wipable tax data.
1539 sub process_batch_reload {
1540 _process_reload('_perform_batch_import', @_);
1544 sub _process_reload {
1545 my ( $method, $job ) = ( shift, shift );
1547 my $param = thaw(decode_base64($_[0]));
1548 my $format = $param->{'format'}; #well... this is all cch specific
1550 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1552 if ( $job ) { # progress bar
1553 my $error = $job->update_statustext( 0 );
1554 die $error if $error;
1557 my $oldAutoCommit = $FS::UID::AutoCommit;
1558 local $FS::UID::AutoCommit = 0;
1563 "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
1564 "USING (taxclassnum) WHERE data_vendor = '$format'";
1565 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1567 or die "Unexpected error executing statement $sql: ". $sth->errstr;
1568 die "Don't (yet) know how to handle part_pkg_taxoverride records."
1569 if $sth->fetchrow_arrayref->[0];
1571 # really should get a table EXCLUSIVE lock here
1573 #remember disabled taxes
1574 my %disabled_tax_rate = ();
1575 $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
1577 #remember tax products
1578 my %taxproduct = ();
1579 $error ||= _remember_tax_products( $job, $format, \%taxproduct );
1582 $error ||= _create_temporary_tables( $job, $format );
1586 my $args = '$job, @_';
1587 eval "$method($args);";
1591 #restore taxproducts
1592 $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
1596 _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
1598 #wipe out the old data
1599 $error ||= _remove_old_tax_data( $job, $format );
1602 $error ||= _copy_from_temp( $job, $format );
1605 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1610 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1614 =item process_download_and_update
1616 Download and process a tax update as a queued JSRPC job
1620 sub process_download_and_update {
1623 my $param = thaw(decode_base64(shift));
1624 my $format = $param->{'format'}; #well... this is all cch specific
1626 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1628 if ( $job ) { # progress bar
1629 my $error = $job->update_statustext( 0);
1630 die $error if $error;
1633 my $cache_dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
1634 my $dir = $cache_dir. 'taxdata';
1636 mkdir $dir or die "can't create $dir: $!\n";
1639 if ($format eq 'cch') {
1641 my @namelist = qw( code detail geocode plus4 txmatrix zip );
1643 my $conf = new FS::Conf;
1644 die "direct download of tax data not enabled\n"
1645 unless $conf->exists('taxdatadirectdownload');
1646 my ( $urls, $username, $secret, $states ) =
1647 $conf->config('taxdatadirectdownload');
1648 die "No tax download URL provided. ".
1649 "Did you set the taxdatadirectdownload configuration value?\n"
1657 # really should get a table EXCLUSIVE lock here
1658 # check if initial import or update
1660 # relying on mkdir "$dir.new" as a mutex
1662 my $sql = "SELECT count(*) from tax_rate WHERE data_vendor='$format'";
1663 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1664 $sth->execute() or die $sth->errstr;
1665 my $update = $sth->fetchrow_arrayref->[0];
1667 # create cache and/or rotate old tax data
1672 opendir(my $dirh, "$dir.9") or die "failed to open $dir.9: $!\n";
1673 foreach my $file (readdir($dirh)) {
1674 unlink "$dir.9/$file" if (-f "$dir.9/$file");
1680 for (8, 7, 6, 5, 4, 3, 2, 1) {
1681 if ( -e "$dir.$_" ) {
1682 rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
1685 rename "$dir", "$dir.1" or die "can't rename $dir: $!\n";
1689 die "can't find previous tax data\n" if $update;
1693 mkdir "$dir.new" or die "can't create $dir.new: $!\n";
1695 # fetch and unpack the zip files
1697 _cch_fetch_and_unzip( $job, $urls, $secret, "$dir.new" );
1699 # extract csv files from the dbf files
1701 foreach my $name ( @namelist ) {
1702 _cch_extract_csv_from_dbf( $job, $dir, $name );
1705 # generate the diff files
1708 foreach my $name ( @namelist ) {
1709 my $difffile = "$dir.new/$name.txt";
1711 my $error = $job->update_statustext( "0,Comparing to previous $name" );
1712 die $error if $error;
1713 warn "processing $dir.new/$name.txt\n" if $DEBUG;
1714 my $olddir = $update ? "$dir.1" : "";
1715 $difffile = _perform_cch_diff( $name, "$dir.new", $olddir );
1717 $difffile =~ s/^$cache_dir//;
1718 push @list, "${name}file:$difffile";
1721 # perform the import
1722 local $keep_cch_files = 1;
1723 $param->{uploaded_files} = join( ',', @list );
1724 $param->{format} .= '-update' if $update;
1726 _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
1728 rename "$dir.new", "$dir"
1729 or die "cch tax update processed, but can't rename $dir.new: $!\n";
1732 die "Unknown format: $format";
1736 =item browse_queries PARAMS
1738 Returns a list consisting of a hashref suited for use as the argument
1739 to qsearch, and sql query string. Each is based on the PARAMS hashref
1740 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
1741 from a form. This conveniently creates the query hashref and count_query
1742 string required by the browse and search elements. As a side effect,
1743 the PARAMS hashref is untainted and keys with unexpected values are removed.
1747 sub browse_queries {
1751 'table' => 'tax_rate',
1753 'order_by' => 'ORDER BY geocode, taxclassnum',
1758 if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1759 $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1761 delete $params->{data_vendor};
1764 if ( $params->{geocode} =~ /^(\w+)$/ ) {
1765 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1766 'geocode LIKE '. dbh->quote($1.'%');
1768 delete $params->{geocode};
1771 if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1772 qsearchs( 'tax_class', {'taxclassnum' => $1} )
1775 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1776 ' taxclassnum = '. dbh->quote($1)
1778 delete $params->{taxclassnun};
1782 if ( $params->{tax_type} =~ /^(\d+)$/ );
1783 delete $params->{tax_type}
1787 if ( $params->{tax_cat} =~ /^(\d+)$/ );
1788 delete $params->{tax_cat}
1791 my @taxclassnum = ();
1792 if ($tax_type || $tax_cat ) {
1793 my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1794 $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1795 @taxclassnum = map { $_->taxclassnum }
1796 qsearch({ 'table' => 'tax_class',
1798 'extra_sql' => "WHERE taxclass $compare",
1802 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1803 join(' OR ', map { " taxclassnum = $_ " } @taxclassnum ). ' )'
1804 if ( @taxclassnum );
1806 unless ($params->{'showdisabled'}) {
1807 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1808 "( disabled = '' OR disabled IS NULL )";
1811 $query->{extra_sql} = $extra_sql;
1813 return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1816 =item queue_liability_report PARAMS
1818 Launches a tax liability report.
1821 sub queue_liability_report {
1823 my $param = thaw(decode_base64(shift));
1826 $cgi->param('beginning', $param->{beginning});
1827 $cgi->param('ending', $param->{ending});
1828 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
1829 my $agentnum = $param->{agentnum};
1830 if ($agentnum =~ /^(\d+)$/) { $agentnum = $1; } else { $agentnum = ''; };
1831 generate_liability_report(
1832 'beginning' => $beginning,
1833 'ending' => $ending,
1834 'agentnum' => $agentnum,
1835 'p' => $param->{RootURL},
1840 =item generate_liability_report PARAMS
1842 Generates a tax liability report. Provide a hash including desired
1843 agentnum, beginning, and ending
1847 #shit, all sorts of false laxiness w/report_newtax.cgi
1848 sub generate_liability_report {
1851 my ( $count, $last, $min_sec ) = _progressbar_foo();
1853 #let us open the temp file early
1854 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
1855 my $report = new File::Temp( TEMPLATE => 'report.tax.liability.XXXXXXXX',
1857 UNLINK => 0, # not so temp
1858 ) or die "can't open report file: $!\n";
1860 my $conf = new FS::Conf;
1861 my $money_char = $conf->config('money_char') || '$';
1864 JOIN cust_bill USING ( invnum )
1865 LEFT JOIN cust_main USING ( custnum )
1869 "LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum )";
1870 my $join_tax_loc = "LEFT JOIN tax_rate_location USING ( taxratelocationnum )";
1872 my $addl_from = " $join_cust $join_loc $join_tax_loc ";
1874 my $where = "WHERE _date >= $args{beginning} AND _date <= $args{ending} ";
1877 if ( $args{agentnum} =~ /^(\d+)$/ ) {
1878 my $agent = qsearchs('agent', { 'agentnum' => $1 } );
1879 die "agent not found" unless $agent;
1880 $agentname = $agent->agent;
1881 $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
1884 #my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
1885 my @taxparams = qw( city county state locationtaxid );
1886 my @params = ('itemdesc', @taxparams);
1888 my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
1890 #false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
1891 #to FS::Report or FS::Record or who the fuck knows where)
1892 my $scalar_sql = sub {
1893 my( $r, $param, $sql ) = @_;
1894 my $sth = dbh->prepare($sql) or die dbh->errstr;
1895 $sth->execute( map $r->$_(), @$param )
1896 or die "Unexpected error executing statement $sql: ". $sth->errstr;
1897 $sth->fetchrow_arrayref->[0] || 0;
1905 my @tax_and_location = qsearch({ table => 'cust_bill_pkg',
1907 hashref => { pkgpart => 0 },
1908 addl_from => $addl_from,
1909 extra_sql => $where,
1911 $count = scalar(@tax_and_location);
1912 foreach my $t ( @tax_and_location ) {
1915 if ( time - $min_sec > $last ) {
1916 $args{job}->update_statustext( int( 100 * $calculated / $count ).
1923 #my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
1924 my $label = join('~', map { $t->$_ } @params);
1925 $label = 'Tax'. $label if $label =~ /^~/;
1926 unless ( exists( $taxes{$label} ) ) {
1927 my ($baselabel, @trash) = split /~/, $label;
1929 $taxes{$label}->{'label'} = join(', ', split(/~/, $label) );
1930 $taxes{$label}->{'url_param'} =
1931 join(';', map { "$_=". uri_escape($t->$_) } @params);
1933 my $payby_itemdesc_loc =
1934 " payby != 'COMP' ".
1935 "AND ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
1936 "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
1941 "FROM cust_bill_pkg $addl_from $where AND $payby_itemdesc_loc";
1943 my $sql = "SELECT SUM(amount) $taxwhere AND cust_bill_pkg.pkgnum = 0";
1945 my $x = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
1947 $taxes{$label}->{'tax'} += $x;
1950 "JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum)";
1952 "FROM cust_bill_pkg $addl_from $creditfrom $where AND $payby_itemdesc_loc";
1954 $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
1955 " $creditwhere AND cust_bill_pkg.pkgnum = 0";
1957 my $y = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
1959 $taxes{$label}->{'credit'} += $y;
1961 unless ( exists( $taxes{$baselabel} ) ) {
1963 $basetaxes{$baselabel}->{'label'} = $baselabel;
1964 $basetaxes{$baselabel}->{'url_param'} = "itemdesc=$baselabel";
1965 $basetaxes{$baselabel}->{'base'} = 1;
1969 $basetaxes{$baselabel}->{'tax'} += $x;
1970 $basetaxes{$baselabel}->{'credit'} += $y;
1974 # calculate customer-exemption for this tax
1975 # calculate package-exemption for this tax
1976 # calculate monthly exemption (texas tax) for this tax
1977 # count up all the cust_tax_exempt_pkg records associated with
1978 # the actual line items.
1985 $args{job}->update_statustext( "0,Sorted" );
1991 foreach my $tax ( sort { $a cmp $b } keys %taxes ) {
1992 my ($base, @trash) = split '~', $tax;
1993 my $basetax = delete( $basetaxes{$base} );
1995 if ( $basetax->{tax} == $taxes{$tax}->{tax} ) {
1996 $taxes{$tax}->{base} = 1;
1998 push @taxes, $basetax;
2001 push @taxes, $taxes{$tax};
2008 'credit' => $credit,
2013 my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
2014 $dateagentlink .= ';agentnum='. $args{agentnum}
2015 if length($agentname);
2016 my $baselink = $args{p}. "search/cust_bill_pkg.cgi?$dateagentlink";
2017 my $creditlink = $args{p}. "search/cust_credit_bill_pkg.html?$dateagentlink";
2019 print $report <<EOF;
2021 <% include("/elements/header.html", "$agentname Tax Report - ".
2023 ? time2str('%h %o %Y ', $args{beginning} )
2027 ( $args{ending} == 4294967295
2029 : time2str('%h %o %Y', $args{ending} )
2034 <% include('/elements/table-grid.html') %>
2037 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2038 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2039 <TH CLASS="grid" BGCOLOR="#cccccc">Tax invoiced</TH>
2040 <TH CLASS="grid" BGCOLOR="#cccccc"> </TH>
2041 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2042 <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
2046 my $bgcolor1 = '#eeeeee';
2047 my $bgcolor2 = '#ffffff';
2050 $count = scalar(@taxes);
2052 foreach my $tax ( @taxes ) {
2055 if ( time - $min_sec > $last ) {
2056 $args{job}->update_statustext( int( 100 * $calculated / $count ).
2063 if ( $bgcolor eq $bgcolor1 ) {
2064 $bgcolor = $bgcolor2;
2066 $bgcolor = $bgcolor1;
2070 if ( $tax->{'label'} ne 'Total' ) {
2071 $link = ';'. $tax->{'url_param'};
2074 print $report <<EOF;
2076 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"><% '$tax->{label}' %></TD>
2077 <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2078 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2079 <A HREF="<% '$baselink$link' %>;istax=1"><% '$money_char' %><% sprintf('%.2f', $tax->{'tax'} ) %></A>
2081 <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2082 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
2083 <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2084 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2085 <A HREF="<% '$creditlink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
2087 <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2092 print $report <<EOF;
2099 my $reportname = $report->filename;
2102 my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
2103 $reportname =~ s/^$dropstring//;
2105 my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname";
2106 die "<a href=$reporturl>view</a>\n";
2116 Mixing automatic and manual editing works poorly at present.
2118 Tax liability calculations take too long and arguably don't belong here.
2119 Tax liability report generation not entirely safe (escaped).
2123 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base