2 use base qw( FS::Record );
5 use vars qw( $DEBUG $me
6 %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
7 %tax_passtypes %GetInfoType $keep_cch_files );
10 use DateTime::Format::Strptime;
11 use Storable qw( thaw nfreeze );
21 use DBIx::DBSchema::Table;
22 use DBIx::DBSchema::Column;
23 use FS::Record qw( qsearch qsearchs dbh dbdef );
26 use FS::cust_bill_pkg;
27 use FS::cust_tax_location;
28 use FS::tax_rate_location;
29 use FS::part_pkg_taxrate;
30 use FS::part_pkg_taxproduct;
32 use FS::Misc qw( csv_from_fixed );
35 $me = '[FS::tax_rate]';
40 FS::tax_rate - Object methods for tax_rate objects
46 $record = new FS::tax_rate \%hash;
47 $record = new FS::tax_rate { 'column' => 'value' };
49 $error = $record->insert;
51 $error = $new_record->replace($old_record);
53 $error = $record->delete;
55 $error = $record->check;
59 An FS::tax_rate object represents a tax rate, defined by locale.
60 FS::tax_rate inherits from FS::Record. The following fields are
67 primary key (assigned automatically for new tax rates)
71 a geographic location code provided by a tax data vendor
79 a location code provided by a tax authority
83 a foreign key into FS::tax_class - the type of tax
84 referenced but FS::part_pkg_taxrate
87 the time after which the tax applies
95 second bracket percentage
99 the amount to which the tax applies (first bracket)
103 a cap on the amount of tax if a cap exists
107 percentage on out of jurisdiction purchases
111 second bracket percentage on out of jurisdiction purchases
115 one of the values in %tax_unittypes
119 amount of tax per unit
123 second bracket amount of tax per unit
127 the number of units to which the fee applies (first bracket)
131 the most units to which fees apply (first and second brackets)
135 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
139 if defined, printed on invoices instead of "Tax"
143 a value from %tax_authorities
147 a value from %tax_basetypes indicating the tax basis
151 a value from %tax_passtypes indicating how the tax should displayed to the customer
155 'Y', 'N', or blank indicating the tax can be passed to the customer
159 if 'Y', this tax does not apply to setup fees
163 if 'Y', this tax does not apply to recurring fees
167 if 'Y', has been manually edited
177 Creates a new tax rate. To add the tax rate to the database, see L<"insert">.
181 sub table { 'tax_rate'; }
185 Adds this tax rate to the database. If there is an error, returns the error,
186 otherwise returns false.
190 Deletes this tax rate from the database. If there is an error, returns the
191 error, otherwise returns false.
193 =item replace OLD_RECORD
195 Replaces the OLD_RECORD with this one in the database. If there is an error,
196 returns the error, otherwise returns false.
200 Checks all fields to make sure this is a valid tax rate. If there is an error,
201 returns the error, otherwise returns false. Called by the insert and replace
209 foreach (qw( taxbase taxmax )) {
210 $self->$_(0) unless $self->$_;
213 $self->ut_numbern('taxnum')
214 || $self->ut_text('geocode')
215 || $self->ut_textn('data_vendor')
216 || $self->ut_cch_textn('location')
217 || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
218 || $self->ut_snumbern('effective_date')
219 || $self->ut_float('tax')
220 || $self->ut_floatn('excessrate')
221 || $self->ut_money('taxbase')
222 || $self->ut_money('taxmax')
223 || $self->ut_floatn('usetax')
224 || $self->ut_floatn('useexcessrate')
225 || $self->ut_numbern('unittype')
226 || $self->ut_floatn('fee')
227 || $self->ut_floatn('excessfee')
228 || $self->ut_floatn('feemax')
229 || $self->ut_numbern('maxtype')
230 || $self->ut_textn('taxname')
231 || $self->ut_numbern('taxauth')
232 || $self->ut_numbern('basetype')
233 || $self->ut_numbern('passtype')
234 || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
235 || $self->ut_enum('setuptax', [ '', 'Y' ] )
236 || $self->ut_enum('recurtax', [ '', 'Y' ] )
237 || $self->ut_enum('inoutcity', [ '', 'I', 'O' ] )
238 || $self->ut_enum('inoutlocal', [ '', 'I', 'O' ] )
239 || $self->ut_enum('manual', [ '', 'Y' ] )
240 || $self->ut_enum('disabled', [ '', 'Y' ] )
241 || $self->SUPER::check
246 #ut_text / ut_textn w/ ` added cause now that's in the data
249 $self->getfield($field)
250 =~ /^([\wô \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>\`]*)$/
251 or return gettext('illegal_or_empty_text'). " $field: ".
252 $self->getfield($field);
253 $self->setfield($field,$1);
258 =item taxclass_description
260 Returns the human understandable value associated with the related
265 sub taxclass_description {
267 my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
268 $tax_class ? $tax_class->description : '';
273 Returns the human understandable value associated with the unittype column
277 %tax_unittypes = ( '0' => 'access line',
284 $tax_unittypes{$self->unittype};
289 Returns the human understandable value associated with the maxtype column
293 %tax_maxtypes = ( '0' => 'receipts per invoice',
294 '1' => 'receipts per item',
295 '2' => 'total utility charges per utility tax year',
296 '3' => 'total charges per utility tax year',
297 '4' => 'receipts per access line',
298 '9' => 'monthly receipts per location',
303 $tax_maxtypes{$self->maxtype};
308 Returns the human understandable value associated with the basetype column
312 %tax_basetypes = ( '0' => 'sale price',
313 '1' => 'gross receipts',
314 '2' => 'sales taxable telecom revenue',
315 '3' => 'minutes carried',
316 '4' => 'minutes billed',
317 '5' => 'gross operating revenue',
318 '6' => 'access line',
320 '8' => 'gross revenue',
321 '9' => 'portion gross receipts attributable to interstate service',
322 '10' => 'access line',
323 '11' => 'gross profits',
324 '12' => 'tariff rate',
326 '15' => 'prior year gross receipts',
331 $tax_basetypes{$self->basetype};
336 Returns the human understandable value associated with the taxauth column
340 %tax_authorities = ( '0' => 'federal',
345 '5' => 'county administered by state',
346 '6' => 'city administered by state',
347 '7' => 'city administered by county',
348 '8' => 'local administered by state',
349 '9' => 'local administered by county',
354 $tax_authorities{$self->taxauth};
359 Returns the human understandable value associated with the passtype column
363 %tax_passtypes = ( '0' => 'separate tax line',
364 '1' => 'separate surcharge line',
365 '2' => 'surcharge not separated',
366 '3' => 'included in base rate',
371 $tax_passtypes{$self->passtype};
374 =item taxline TAXABLES
376 Returns a listref of a name and an amount of tax calculated for the list
377 of packages/amounts referenced by TAXABLES. If an error occurs, a message
378 is returned as a scalar.
384 # this used to accept a hash of options but none of them did anything
385 # so it's been removed.
389 if (ref($_[0]) eq 'ARRAY') {
393 #exemptions would be broken in this case
396 my $name = $self->taxname;
397 $name = 'Other surcharges'
398 if ($self->passtype == 2);
401 if ( $self->disabled ) { # we always know how to handle disabled taxes
408 my $taxable_charged = 0;
409 my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
412 warn "calculating taxes for ". $self->taxnum. " on ".
413 join (",", map { $_->pkgnum } @cust_bill_pkg)
416 if ($self->passflag eq 'N') {
417 # return "fatal: can't (yet) handle taxes not passed to the customer";
418 # until someone needs to track these in freeside
425 my $maxtype = $self->maxtype || 0;
426 if ($maxtype != 0 && $maxtype != 1 && $maxtype != 9) {
427 return $self->_fatal_or_null( 'tax with "'.
428 $self->maxtype_name. '" threshold'
434 $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' );
438 # we treat gross revenue as gross receipts and expect the tax data
439 # to DTRT (i.e. tax on tax rules)
440 if ($self->basetype != 0 && $self->basetype != 1 &&
441 $self->basetype != 5 && $self->basetype != 6 &&
442 $self->basetype != 7 && $self->basetype != 8 &&
443 $self->basetype != 14
446 $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
449 unless ($self->setuptax =~ /^Y$/i) {
450 $taxable_charged += $_->setup foreach @cust_bill_pkg;
452 unless ($self->recurtax =~ /^Y$/i) {
453 $taxable_charged += $_->recur foreach @cust_bill_pkg;
456 my $taxable_units = 0;
457 unless ($self->recurtax =~ /^Y$/i) {
459 if (( $self->unittype || 0 ) == 0) { #access line
461 foreach (@cust_bill_pkg) {
462 $taxable_units += $_->units
463 unless $seen{$_->pkgnum}++;
466 } elsif ($self->unittype == 1) { #minute
467 return $self->_fatal_or_null( 'fee with minute unit type' );
469 } elsif ($self->unittype == 2) { #account
471 my $conf = new FS::Conf;
472 if ( $conf->exists('tax-pkg_address') ) {
473 #number of distinct locations
475 foreach (@cust_bill_pkg) {
477 unless $seen{$_->cust_pkg->locationnum}++;
484 return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
489 # XXX handle excessrate (use_excessrate) / excessfee /
490 # taxbase/feebase / taxmax/feemax
491 # and eventually exemptions
493 # the tax or fee is applied to taxbase or feebase and then
494 # the excessrate or excess fee is applied to taxmax or feemax
496 $amount += $taxable_charged * $self->tax;
497 $amount += $taxable_units * $self->fee;
499 warn "calculated taxes as [ $name, $amount ]\n"
510 my ($self, $error) = @_;
512 my $conf = new FS::Conf;
514 $error = "can't yet handle ". $error;
515 my $name = $self->taxname;
516 $name = 'Other surcharges'
517 if ($self->passtype == 2);
519 if ($conf->exists('ignore_incalculable_taxes')) {
520 warn "WARNING: $error; billing anyway per ignore_incalculable_taxes conf\n";
521 return { name => $name, amount => 0 };
523 return "fatal: $error";
527 =item tax_on_tax CUST_LOCATION
529 Returns a list of taxes which are candidates for taxing taxes for the
530 given service location (see L<FS::cust_location>)
538 my $cust_location = shift;
540 warn "looking up taxes on tax ". $self->taxnum. " for customer ".
541 $cust_location->custnum
544 my $geocode = $cust_location->geocode($self->data_vendor);
548 my $extra_sql = ' AND ('.
549 join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
554 my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
555 my $select = 'DISTINCT ON(taxclassnum) *';
557 # should qsearch preface columns with the table to facilitate joins?
558 my @taxclassnums = map { $_->taxclassnum }
559 qsearch( { 'table' => 'part_pkg_taxrate',
561 'hashref' => { 'data_vendor' => $self->data_vendor,
562 'taxclassnumtaxed' => $self->taxclassnum,
564 'extra_sql' => $extra_sql,
565 'order_by' => $order_by,
568 return () unless @taxclassnums;
571 "AND (". join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
573 qsearch({ 'table' => 'tax_rate',
574 'hashref' => { 'geocode' => $geocode, },
575 'extra_sql' => $extra_sql,
580 =item tax_rate_location
582 Returns an object representing the location associated with this tax
583 (see L<FS::tax_rate_location>)
587 sub tax_rate_location {
590 qsearchs({ 'table' => 'tax_rate_location',
591 'hashref' => { 'data_vendor' => $self->data_vendor,
592 'geocode' => $self->geocode,
596 new FS::tax_rate_location;
610 sub _progressbar_foo {
615 my ($param, $job) = @_;
617 my $fh = $param->{filehandle};
618 my $format = $param->{'format'};
626 my @column_lengths = ();
627 my @column_callbacks = ();
628 if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
629 $format =~ s/-fixed//;
630 my $date_format = sub { my $r='';
631 /^(\d{4})(\d{2})(\d{2})$/ && ($r="$3/$2/$1");
634 my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
635 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 );
636 push @column_lengths, 1 if $format eq 'cch-update';
637 push @column_callbacks, $trim foreach (@column_lengths); # 5, 6, 15, 17 esp
638 $column_callbacks[8] = $date_format;
642 my ( $count, $last, $min_sec ) = _progressbar_foo();
643 if ( $job || scalar(@column_callbacks) ) {
645 csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
646 return $error if $error;
650 if ( $format eq 'cch' || $format eq 'cch-update' ) {
651 #false laziness w/below (sub _perform_cch_diff)
652 @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
653 excessrate effective_date taxauth taxtype taxcat taxname
654 usetax useexcessrate fee unittype feemax maxtype passflag
656 push @fields, 'actionflag' if $format eq 'cch-update';
661 $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
662 $hash->{'data_vendor'} ='cch';
663 my $parser = new DateTime::Format::Strptime( pattern => "%m/%d/%Y",
664 time_zone => 'floating',
666 my $dt = $parser->parse_datetime( $hash->{'effective_date'} );
667 $hash->{'effective_date'} = $dt ? $dt->epoch : '';
669 $hash->{$_} =~ s/\s//g foreach qw( inoutcity inoutlocal ) ;
670 $hash->{$_} = sprintf("%.2f", $hash->{$_}) foreach qw( taxbase taxmax );
673 join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
675 my %tax_class = ( 'data_vendor' => 'cch',
676 'taxclass' => $taxclassid,
679 my $tax_class = qsearchs( 'tax_class', \%tax_class );
680 return "Error updating tax rate: no tax class $taxclassid"
683 $hash->{'taxclassnum'} = $tax_class->taxclassnum;
685 foreach (qw( taxtype taxcat )) {
689 my %passflagmap = ( '0' => '',
693 $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
694 if exists $passflagmap{$hash->{'passflag'}};
696 foreach (keys %$hash) {
697 $hash->{$_} = substr($hash->{$_}, 0, 80)
698 if length($hash->{$_}) > 80;
701 my $actionflag = delete($hash->{'actionflag'});
703 $hash->{'taxname'} =~ s/`/'/g;
704 $hash->{'taxname'} =~ s|\\|/|g;
706 return '' if $format eq 'cch'; # but not cch-update
708 if ($actionflag eq 'I') {
709 $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
710 }elsif ($actionflag eq 'D') {
711 $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
713 return "Unexpected action flag: ". $hash->{'actionflag'};
716 delete($hash->{$_}) for keys %$hash;
722 } elsif ( $format eq 'extended' ) {
723 die "unimplemented\n";
727 die "unknown format $format";
730 my $csv = new Text::CSV_XS;
734 local $SIG{HUP} = 'IGNORE';
735 local $SIG{INT} = 'IGNORE';
736 local $SIG{QUIT} = 'IGNORE';
737 local $SIG{TERM} = 'IGNORE';
738 local $SIG{TSTP} = 'IGNORE';
739 local $SIG{PIPE} = 'IGNORE';
741 my $oldAutoCommit = $FS::UID::AutoCommit;
742 local $FS::UID::AutoCommit = 0;
745 while ( defined($line=<$fh>) ) {
746 $csv->parse($line) or do {
747 $dbh->rollback if $oldAutoCommit;
748 return "can't parse: ". $csv->error_input();
751 if ( $job ) { # progress bar
752 if ( time - $min_sec > $last ) {
753 my $error = $job->update_statustext(
754 int( 100 * $imported / $count ). ",Importing tax rates"
757 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
764 my @columns = $csv->fields();
766 my %tax_rate = ( 'data_vendor' => $format );
767 foreach my $field ( @fields ) {
768 $tax_rate{$field} = shift @columns;
771 if ( scalar( @columns ) ) {
772 $dbh->rollback if $oldAutoCommit;
773 return "Unexpected trailing columns in line (wrong format?) importing tax_rate: $line";
776 my $error = &{$hook}(\%tax_rate);
778 $dbh->rollback if $oldAutoCommit;
782 if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
784 my $tax_rate = new FS::tax_rate( \%tax_rate );
785 $error = $tax_rate->insert;
788 $dbh->rollback if $oldAutoCommit;
789 return "can't insert tax_rate for $line: $error";
798 my @replace = grep { exists($delete{$_}) } keys %insert;
800 if ( $job ) { # progress bar
801 if ( time - $min_sec > $last ) {
802 my $error = $job->update_statustext(
803 int( 100 * $imported / $count ). ",Importing tax rates"
806 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
813 my $old = qsearchs( 'tax_rate', $delete{$_} );
817 my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => '' });
818 $new->taxnum($old->taxnum);
819 my $error = $new->replace($old);
822 $dbh->rollback if $oldAutoCommit;
823 my $hashref = $insert{$_};
824 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
825 return "can't replace tax_rate for $line: $error";
832 $old = delete $delete{$_};
833 warn "WARNING: can't find tax_rate to replace (inserting instead and continuing) for: ".
834 #join(" ", map { "$_ => ". $old->{$_} } @fields);
835 join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
841 for (grep { !exists($delete{$_}) } keys %insert) {
842 if ( $job ) { # progress bar
843 if ( time - $min_sec > $last ) {
844 my $error = $job->update_statustext(
845 int( 100 * $imported / $count ). ",Importing tax rates"
848 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
855 my $tax_rate = new FS::tax_rate( $insert{$_} );
856 my $error = $tax_rate->insert;
859 $dbh->rollback if $oldAutoCommit;
860 my $hashref = $insert{$_};
861 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
862 return "can't insert tax_rate for $line: $error";
868 for (grep { !exists($insert{$_}) } keys %delete) {
869 if ( $job ) { # progress bar
870 if ( time - $min_sec > $last ) {
871 my $error = $job->update_statustext(
872 int( 100 * $imported / $count ). ",Importing tax rates"
875 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
882 my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
884 $dbh->rollback if $oldAutoCommit;
885 $tax_rate = $delete{$_};
886 return "can't find tax_rate to delete for: ".
887 #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
888 join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
890 my $error = $tax_rate->delete;
893 $dbh->rollback if $oldAutoCommit;
894 my $hashref = $delete{$_};
895 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
896 return "can't delete tax_rate for $line: $error";
902 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
904 return "Empty file!" unless ($imported || $format eq 'cch-update');
910 =item process_batch_import
912 Load a batch import as a queued JSRPC job
916 sub process_batch_import {
919 my $oldAutoCommit = $FS::UID::AutoCommit;
920 local $FS::UID::AutoCommit = 0;
923 my $param = thaw(decode_base64(shift));
924 my $args = '$job, encode_base64( nfreeze( $param ) )';
926 my $method = '_perform_batch_import';
927 if ( $param->{reload} ) {
928 $method = 'process_batch_reload';
931 eval "$method($args);";
933 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
938 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
941 sub _perform_batch_import {
944 my $param = thaw(decode_base64(shift));
945 my $format = $param->{'format'}; #well... this is all cch specific
947 my $files = $param->{'uploaded_files'}
948 or die "No files provided.";
950 my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
953 if ( $format eq 'cch' || $format eq 'cch-fixed'
954 || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
957 my $oldAutoCommit = $FS::UID::AutoCommit;
958 local $FS::UID::AutoCommit = 0;
961 my @insert_list = ();
962 my @delete_list = ();
963 my @predelete_list = ();
966 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
968 my @list = ( 'GEOCODE', \&FS::tax_rate_location::batch_import,
969 'CODE', \&FS::tax_class::batch_import,
970 'PLUS4', \&FS::cust_tax_location::batch_import,
971 'ZIP', \&FS::cust_tax_location::batch_import,
972 'TXMATRIX', \&FS::part_pkg_taxrate::batch_import,
973 'DETAIL', \&FS::tax_rate::batch_import,
975 while( scalar(@list) ) {
976 my ( $name, $import_sub ) = splice( @list, 0, 2 );
977 my $file = lc($name). 'file';
979 unless ($files{$file}) {
980 #$error = "No $name supplied";
983 next if $name eq 'DETAIL' && $format =~ /update/;
985 my $filename = "$dir/". $files{$file};
987 if ( $format =~ /update/ ) {
989 ( $error, $insertname, $deletename ) =
990 _perform_cch_insert_delete_split( $name, $filename, $dir, $format )
994 unlink $filename or warn "Can't delete $filename: $!"
995 unless $keep_cch_files;
996 push @insert_list, $name, $insertname, $import_sub, $format;
997 if ( $name eq 'GEOCODE' || $name eq 'CODE' ) { #handle this whole ordering issue better
998 unshift @predelete_list, $name, $deletename, $import_sub, $format;
1000 unshift @delete_list, $name, $deletename, $import_sub, $format;
1005 push @insert_list, $name, $filename, $import_sub, $format;
1012 'DETAIL', "$dir/".$files{detailfile}, \&FS::tax_rate::batch_import, $format
1013 if $format =~ /update/;
1015 my %addl_param = ();
1016 if ( $param->{'delete_only'} ) {
1017 $addl_param{'delete_only'} = $param->{'delete_only'};
1021 $error ||= _perform_cch_tax_import( $job,
1022 [ @predelete_list ],
1029 @list = ( @predelete_list, @insert_list, @delete_list );
1030 while( !$keep_cch_files && scalar(@list) ) {
1031 my ( undef, $file, undef, undef ) = splice( @list, 0, 4 );
1032 unlink $file or warn "Can't delete $file: $!";
1036 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1039 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1043 die "Unknown format: $format";
1049 sub _perform_cch_tax_import {
1050 my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
1054 foreach my $list ($predelete_list, $insert_list, $delete_list) {
1055 while( scalar(@$list) ) {
1056 my ( $name, $file, $method, $format ) = splice( @$list, 0, 4 );
1057 my $fmt = "$format-update";
1058 $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
1059 open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
1060 my $param = { 'filehandle' => $fh,
1064 $error ||= &{$method}($param, $job);
1072 sub _perform_cch_insert_delete_split {
1073 my ($name, $filename, $dir, $format) = @_;
1077 open my $fh, "< $filename"
1078 or $error ||= "Can't open $name file $filename: $!";
1080 my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
1083 ) or die "can't open temp file: $!\n";
1084 my $insertname = $ifh->filename;
1086 my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
1089 ) or die "can't open temp file: $!\n";
1090 my $deletename = $dfh->filename;
1092 my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
1093 my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
1096 $handle = $ifh if $_ =~ /$insert_pattern/;
1097 $handle = $dfh if $_ =~ /$delete_pattern/;
1099 $error = "bad input line: $_" unless $handle;
1108 return ($error, $insertname, $deletename);
1111 sub _perform_cch_diff {
1112 my ($name, $newdir, $olddir) = @_;
1117 open my $oldcsvfh, "$olddir/$name.txt"
1118 or die "failed to open $olddir/$name.txt: $!\n";
1120 while(<$oldcsvfh>) {
1127 open my $newcsvfh, "$newdir/$name.txt"
1128 or die "failed to open $newdir/$name.txt: $!\n";
1130 my $dfh = new File::Temp( TEMPLATE => "$name.diff.XXXXXXXX",
1133 ) or die "can't open temp file: $!\n";
1134 my $diffname = $dfh->filename;
1136 while(<$newcsvfh>) {
1138 if (exists($oldlines{$_})) {
1141 print $dfh $_, ',"I"', "\n";
1146 #false laziness w/above (sub batch_import)
1147 my @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
1148 excessrate effective_date taxauth taxtype taxcat taxname
1149 usetax useexcessrate fee unittype feemax maxtype passflag
1150 passtype basetype );
1151 my $numfields = scalar(@fields);
1153 my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1155 for my $line (grep $oldlines{$_}, keys %oldlines) {
1157 $csv->parse($line) or do {
1158 #$dbh->rollback if $oldAutoCommit;
1159 die "can't parse: ". $csv->error_input();
1161 my @columns = $csv->fields();
1163 $csv->combine( splice(@columns, 0, $numfields) );
1165 print $dfh $csv->string, ',"D"', "\n";
1173 sub _cch_fetch_and_unzip {
1174 my ( $job, $urls, $secret, $dir ) = @_;
1176 my $ua = new LWP::UserAgent;
1177 foreach my $url (split ',', $urls) {
1178 my @name = split '/', $url; #somewhat restrictive
1179 my $name = pop @name;
1180 $name =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1183 open my $taxfh, ">$dir/$name" or die "Can't open $dir/$name: $!\n";
1185 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1186 my $res = $ua->request(
1187 new HTTP::Request( GET => $url ),
1189 print $taxfh $_[0] or die "Can't write to $dir/$name: $!\n";
1190 my $content_length = $_[1]->content_length;
1191 $imported += length($_[0]);
1192 if ( time - $min_sec > $last ) {
1193 my $error = $job->update_statustext(
1194 ($content_length ? int(100 * $imported/$content_length) : 0 ).
1195 ",Downloading data from CCH"
1197 die $error if $error;
1202 die "download of $url failed: ". $res->status_line
1203 unless $res->is_success;
1206 my $error = $job->update_statustext( "0,Unpacking data" );
1207 die $error if $error;
1208 $secret =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1210 system('unzip', "-P", $secret, "-d", "$dir", "$dir/$name") == 0
1211 or die "unzip -P $secret -d $dir $dir/$name failed";
1212 #unlink "$dir/$name";
1216 sub _cch_extract_csv_from_dbf {
1217 my ( $job, $dir, $name ) = @_;
1222 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1223 my $error = $job->update_statustext( "0,Unpacking $name" );
1224 die $error if $error;
1225 warn "opening $dir.new/$name.dbf\n" if $DEBUG;
1226 my $table = new XBase 'name' => "$dir.new/$name.dbf";
1227 die "failed to access $dir.new/$name.dbf: ". XBase->errstr
1228 unless defined($table);
1229 my $count = $table->last_record; # approximately;
1230 open my $csvfh, ">$dir.new/$name.txt"
1231 or die "failed to open $dir.new/$name.txt: $!\n";
1233 my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1234 my @fields = $table->field_names;
1235 my $cursor = $table->prepare_select;
1237 sub { my $date = shift;
1238 $date =~ /^(\d{4})(\d{2})(\d{2})$/ && ($date = "$2/$3/$1");
1241 while (my $row = $cursor->fetch_hashref) {
1242 $csv->combine( map { my $type = $table->field_type($_);
1244 &{$format_date}($row->{$_}) ;
1245 } elsif ($type eq 'N' && $row->{$_} =~ /e-/i ) {
1246 sprintf('%.8f', $row->{$_}); #db row is numeric(14,8)
1253 print $csvfh $csv->string, "\n";
1255 if ( time - $min_sec > $last ) {
1256 my $error = $job->update_statustext(
1257 int(100 * $imported/$count). ",Unpacking $name"
1259 die $error if $error;
1267 sub _remember_disabled_taxes {
1268 my ( $job, $format, $disabled_tax_rate ) = @_;
1272 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1274 my @items = qsearch( { table => 'tax_rate',
1275 hashref => { disabled => 'Y',
1276 data_vendor => $format,
1278 select => 'geocode, taxclassnum',
1281 my $count = scalar(@items);
1282 foreach my $tax_rate ( @items ) {
1283 if ( time - $min_sec > $last ) {
1284 $job->update_statustext(
1285 int( 100 * $imported / $count ). ",Remembering disabled taxes"
1291 qsearchs( 'tax_class', { taxclassnum => $tax_rate->taxclassnum } );
1292 unless ( $tax_class ) {
1293 warn "failed to find tax_class ". $tax_rate->taxclassnum;
1296 $disabled_tax_rate->{$tax_rate->geocode. ':'. $tax_class->taxclass} = 1;
1300 sub _remember_tax_products {
1301 my ( $job, $format, $taxproduct ) = @_;
1303 # XXX FIXME this loop only works when cch is the only data provider
1305 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1307 my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
1308 "0 < ( SELECT count(*) from part_pkg_option WHERE ".
1309 " part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
1310 " optionname LIKE 'usage_taxproductnum_%' AND ".
1311 " optionvalue != '' )";
1312 my @items = qsearch( { table => 'part_pkg',
1313 select => 'DISTINCT pkgpart,taxproductnum',
1315 extra_sql => $extra_sql,
1318 my $count = scalar(@items);
1319 foreach my $part_pkg ( @items ) {
1320 if ( time - $min_sec > $last ) {
1321 $job->update_statustext(
1322 int( 100 * $imported / $count ). ",Remembering tax products"
1327 warn "working with package part ". $part_pkg->pkgpart.
1328 "which has a taxproductnum of ". $part_pkg->taxproductnum. "\n" if $DEBUG;
1329 my $part_pkg_taxproduct = $part_pkg->taxproduct('');
1330 $taxproduct->{$part_pkg->pkgpart}->{''} = $part_pkg_taxproduct->taxproduct
1331 if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1333 foreach my $option ( $part_pkg->part_pkg_option ) {
1334 next unless $option->optionname =~ /^usage_taxproductnum_(\w+)$/;
1337 $part_pkg_taxproduct = $part_pkg->taxproduct($class);
1338 $taxproduct->{$part_pkg->pkgpart}->{$class} =
1339 $part_pkg_taxproduct->taxproduct
1340 if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1345 sub _restore_remembered_tax_products {
1346 my ( $job, $format, $taxproduct ) = @_;
1350 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1351 my $count = scalar(keys %$taxproduct);
1352 foreach my $pkgpart ( keys %$taxproduct ) {
1353 warn "restoring taxproductnums on pkgpart $pkgpart\n" if $DEBUG;
1354 if ( time - $min_sec > $last ) {
1355 $job->update_statustext(
1356 int( 100 * $imported / $count ). ",Restoring tax products"
1362 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkgpart } );
1363 unless ( $part_pkg ) {
1364 return "somehow failed to find part_pkg with pkgpart $pkgpart!\n";
1367 my %options = $part_pkg->options;
1368 my %pkg_svc = map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc;
1369 my $primary_svc = $part_pkg->svcpart;
1370 my $new = new FS::part_pkg { $part_pkg->hash };
1372 foreach my $class ( keys %{ $taxproduct->{$pkgpart} } ) {
1373 warn "working with class '$class'\n" if $DEBUG;
1374 my $part_pkg_taxproduct =
1375 qsearchs( 'part_pkg_taxproduct',
1376 { taxproduct => $taxproduct->{$pkgpart}->{$class},
1377 data_vendor => $format,
1381 unless ( $part_pkg_taxproduct ) {
1382 return "failed to find part_pkg_taxproduct (".
1383 $taxproduct->{$pkgpart}->{$class}. ") for pkgpart $pkgpart\n";
1386 if ( $class eq '' ) {
1387 $new->taxproductnum($part_pkg_taxproduct->taxproductnum);
1391 $options{"usage_taxproductnum_$class"} =
1392 $part_pkg_taxproduct->taxproductnum;
1396 my $error = $new->replace( $part_pkg,
1397 'pkg_svc' => \%pkg_svc,
1398 'primary_svc' => $primary_svc,
1399 'options' => \%options,
1402 return $error if $error;
1409 sub _restore_remembered_disabled_taxes {
1410 my ( $job, $format, $disabled_tax_rate ) = @_;
1412 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1413 my $count = scalar(keys %$disabled_tax_rate);
1414 foreach my $key (keys %$disabled_tax_rate) {
1415 if ( time - $min_sec > $last ) {
1416 $job->update_statustext(
1417 int( 100 * $imported / $count ). ",Disabling tax rates"
1422 my ($geocode,$taxclass) = split /:/, $key, 2;
1423 my @tax_class = qsearch( 'tax_class', { data_vendor => $format,
1424 taxclass => $taxclass,
1426 return "found multiple tax_class records for format $format class $taxclass"
1427 if scalar(@tax_class) > 1;
1429 unless (scalar(@tax_class)) {
1430 warn "no tax_class for format $format class $taxclass\n";
1435 qsearch('tax_rate', { data_vendor => $format,
1436 geocode => $geocode,
1437 taxclassnum => $tax_class[0]->taxclassnum,
1441 if (scalar(@tax_rate) > 1) {
1442 return "found multiple tax_rate records for format $format geocode ".
1443 "$geocode and taxclass $taxclass ( taxclassnum ".
1444 $tax_class[0]->taxclassnum. " )";
1447 if (scalar(@tax_rate)) {
1448 $tax_rate[0]->disabled('Y');
1449 my $error = $tax_rate[0]->replace;
1450 return $error if $error;
1455 sub _remove_old_tax_data {
1456 my ( $job, $format ) = @_;
1459 my $error = $job->update_statustext( "0,Removing old tax data" );
1460 die $error if $error;
1462 my $sql = "UPDATE public.tax_rate_location SET disabled='Y' ".
1463 "WHERE data_vendor = ". $dbh->quote($format);
1464 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1467 tax_rate part_pkg_taxrate part_pkg_taxproduct tax_class cust_tax_location
1469 foreach my $table ( @table ) {
1470 $sql = "DELETE FROM public.$table WHERE data_vendor = ".
1471 $dbh->quote($format);
1472 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1475 if ( $format eq 'cch' ) {
1476 $sql = "DELETE FROM public.cust_tax_location WHERE data_vendor = ".
1477 $dbh->quote("$format-zip");
1478 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1484 sub _create_temporary_tables {
1485 my ( $job, $format ) = @_;
1488 my $error = $job->update_statustext( "0,Creating temporary tables" );
1489 die $error if $error;
1491 my @table = qw( tax_rate
1498 foreach my $table ( @table ) {
1500 "CREATE TEMPORARY TABLE $table ( LIKE $table INCLUDING DEFAULTS )";
1501 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1507 sub _copy_from_temp {
1508 my ( $job, $format ) = @_;
1511 my $error = $job->update_statustext( "0,Making permanent" );
1512 die $error if $error;
1514 my @table = qw( tax_rate
1521 foreach my $table ( @table ) {
1523 "INSERT INTO public.$table SELECT * from $table";
1524 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1530 =item process_download_and_reload
1532 Download and process a tax update as a queued JSRPC job after wiping the
1533 existing wipable tax data.
1537 sub process_download_and_reload {
1538 _process_reload('process_download_and_update', @_);
1542 =item process_batch_reload
1544 Load and process a tax update from the provided files as a queued JSRPC job
1545 after wiping the existing wipable tax data.
1549 sub process_batch_reload {
1550 _process_reload('_perform_batch_import', @_);
1554 sub _process_reload {
1555 my ( $method, $job ) = ( shift, shift );
1557 my $param = thaw(decode_base64($_[0]));
1558 my $format = $param->{'format'}; #well... this is all cch specific
1560 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1562 if ( $job ) { # progress bar
1563 my $error = $job->update_statustext( 0 );
1564 die $error if $error;
1567 my $oldAutoCommit = $FS::UID::AutoCommit;
1568 local $FS::UID::AutoCommit = 0;
1573 "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
1574 "USING (taxclassnum) WHERE data_vendor = '$format'";
1575 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1577 or die "Unexpected error executing statement $sql: ". $sth->errstr;
1578 die "Don't (yet) know how to handle part_pkg_taxoverride records."
1579 if $sth->fetchrow_arrayref->[0];
1581 # really should get a table EXCLUSIVE lock here
1583 #remember disabled taxes
1584 my %disabled_tax_rate = ();
1585 $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
1587 #remember tax products
1588 my %taxproduct = ();
1589 $error ||= _remember_tax_products( $job, $format, \%taxproduct );
1592 $error ||= _create_temporary_tables( $job, $format );
1596 my $args = '$job, @_';
1597 eval "$method($args);";
1601 #restore taxproducts
1602 $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
1606 _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
1608 #wipe out the old data
1609 $error ||= _remove_old_tax_data( $job, $format );
1612 $error ||= _copy_from_temp( $job, $format );
1615 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1620 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1624 =item process_download_and_update
1626 Download and process a tax update as a queued JSRPC job
1630 sub process_download_and_update {
1633 my $param = thaw(decode_base64(shift));
1634 my $format = $param->{'format'}; #well... this is all cch specific
1636 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1638 if ( $job ) { # progress bar
1639 my $error = $job->update_statustext( 0);
1640 die $error if $error;
1643 my $cache_dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
1644 my $dir = $cache_dir. 'taxdata';
1646 mkdir $dir or die "can't create $dir: $!\n";
1649 if ($format eq 'cch') {
1651 my @namelist = qw( code detail geocode plus4 txmatrix zip );
1653 my $conf = new FS::Conf;
1654 die "direct download of tax data not enabled\n"
1655 unless $conf->exists('taxdatadirectdownload');
1656 my ( $urls, $username, $secret, $states ) =
1657 $conf->config('taxdatadirectdownload');
1658 die "No tax download URL provided. ".
1659 "Did you set the taxdatadirectdownload configuration value?\n"
1667 # really should get a table EXCLUSIVE lock here
1668 # check if initial import or update
1670 # relying on mkdir "$dir.new" as a mutex
1672 my $sql = "SELECT count(*) from tax_rate WHERE data_vendor='$format'";
1673 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1674 $sth->execute() or die $sth->errstr;
1675 my $update = $sth->fetchrow_arrayref->[0];
1677 # create cache and/or rotate old tax data
1682 opendir(my $dirh, "$dir.9") or die "failed to open $dir.9: $!\n";
1683 foreach my $file (readdir($dirh)) {
1684 unlink "$dir.9/$file" if (-f "$dir.9/$file");
1690 for (8, 7, 6, 5, 4, 3, 2, 1) {
1691 if ( -e "$dir.$_" ) {
1692 rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
1695 rename "$dir", "$dir.1" or die "can't rename $dir: $!\n";
1699 die "can't find previous tax data\n" if $update;
1703 mkdir "$dir.new" or die "can't create $dir.new: $!\n";
1705 # fetch and unpack the zip files
1707 _cch_fetch_and_unzip( $job, $urls, $secret, "$dir.new" );
1709 # extract csv files from the dbf files
1711 foreach my $name ( @namelist ) {
1712 _cch_extract_csv_from_dbf( $job, $dir, $name );
1715 # generate the diff files
1718 foreach my $name ( @namelist ) {
1719 my $difffile = "$dir.new/$name.txt";
1721 my $error = $job->update_statustext( "0,Comparing to previous $name" );
1722 die $error if $error;
1723 warn "processing $dir.new/$name.txt\n" if $DEBUG;
1724 my $olddir = $update ? "$dir.1" : "";
1725 $difffile = _perform_cch_diff( $name, "$dir.new", $olddir );
1727 $difffile =~ s/^$cache_dir//;
1728 push @list, "${name}file:$difffile";
1731 # perform the import
1732 local $keep_cch_files = 1;
1733 $param->{uploaded_files} = join( ',', @list );
1734 $param->{format} .= '-update' if $update;
1736 _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
1738 rename "$dir.new", "$dir"
1739 or die "cch tax update processed, but can't rename $dir.new: $!\n";
1742 die "Unknown format: $format";
1746 =item browse_queries PARAMS
1748 Returns a list consisting of a hashref suited for use as the argument
1749 to qsearch, and sql query string. Each is based on the PARAMS hashref
1750 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
1751 from a form. This conveniently creates the query hashref and count_query
1752 string required by the browse and search elements. As a side effect,
1753 the PARAMS hashref is untainted and keys with unexpected values are removed.
1757 sub browse_queries {
1761 'table' => 'tax_rate',
1763 'order_by' => 'ORDER BY geocode, taxclassnum',
1768 if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1769 $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1771 delete $params->{data_vendor};
1774 if ( $params->{geocode} =~ /^(\w+)$/ ) {
1775 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1776 'geocode LIKE '. dbh->quote($1.'%');
1778 delete $params->{geocode};
1781 if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1782 qsearchs( 'tax_class', {'taxclassnum' => $1} )
1785 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1786 ' taxclassnum = '. dbh->quote($1)
1788 delete $params->{taxclassnun};
1792 if ( $params->{tax_type} =~ /^(\d+)$/ );
1793 delete $params->{tax_type}
1797 if ( $params->{tax_cat} =~ /^(\d+)$/ );
1798 delete $params->{tax_cat}
1801 my @taxclassnum = ();
1802 if ($tax_type || $tax_cat ) {
1803 my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1804 $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1805 @taxclassnum = map { $_->taxclassnum }
1806 qsearch({ 'table' => 'tax_class',
1808 'extra_sql' => "WHERE taxclass $compare",
1812 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1813 join(' OR ', map { " taxclassnum = $_ " } @taxclassnum ). ' )'
1814 if ( @taxclassnum );
1816 unless ($params->{'showdisabled'}) {
1817 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1818 "( disabled = '' OR disabled IS NULL )";
1821 $query->{extra_sql} = $extra_sql;
1823 return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1826 =item queue_liability_report PARAMS
1828 Launches a tax liability report.
1830 PARAMS needs to be a base64-encoded Storable hash containing:
1831 - beginning: the start date, as a I<user-readable string> (not a timestamp).
1832 - end: the end date of the report, likewise.
1833 - agentnum: the agent to limit the report to, if any.
1837 sub queue_liability_report {
1839 my $param = thaw(decode_base64(shift));
1842 $cgi->param('beginning', $param->{beginning});
1843 $cgi->param('ending', $param->{ending});
1844 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
1845 my $agentnum = $param->{agentnum};
1846 if ($agentnum =~ /^(\d+)$/) { $agentnum = $1; } else { $agentnum = ''; };
1847 generate_liability_report(
1848 'beginning' => $beginning,
1849 'ending' => $ending,
1850 'agentnum' => $agentnum,
1851 'p' => $param->{RootURL},
1856 =item generate_liability_report PARAMS
1858 Generates a tax liability report. PARAMS must include:
1860 - beginning, as a timestamp
1861 - ending, as a timestamp
1862 - p: the Freeside root URL, for generating links
1863 - agentnum (optional)
1867 #shit, all sorts of false laxiness w/report_newtax.cgi
1868 sub generate_liability_report {
1871 my ( $count, $last, $min_sec ) = _progressbar_foo();
1873 #let us open the temp file early
1874 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
1875 my $report = new File::Temp( TEMPLATE => 'report.tax.liability.XXXXXXXX',
1877 UNLINK => 0, # not so temp
1878 ) or die "can't open report file: $!\n";
1880 my $conf = new FS::Conf;
1881 my $money_char = $conf->config('money_char') || '$';
1884 JOIN cust_bill USING ( invnum )
1885 LEFT JOIN cust_main USING ( custnum )
1889 "LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum )";
1890 my $join_tax_loc = "LEFT JOIN tax_rate_location USING ( taxratelocationnum )";
1892 my $addl_from = " $join_cust $join_loc $join_tax_loc ";
1894 my $where = "WHERE _date >= $args{beginning} AND _date <= $args{ending} ";
1897 if ( $args{agentnum} =~ /^(\d+)$/ ) {
1898 my $agent = qsearchs('agent', { 'agentnum' => $1 } );
1899 die "agent not found" unless $agent;
1900 $agentname = $agent->agent;
1901 $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
1904 #my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
1905 my @taxparams = qw( city county state locationtaxid );
1906 my @params = ('itemdesc', @taxparams);
1908 my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
1910 #false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
1911 #to FS::Report or FS::Record or who the fuck knows where)
1912 my $scalar_sql = sub {
1913 my( $r, $param, $sql ) = @_;
1914 my $sth = dbh->prepare($sql) or die dbh->errstr;
1915 $sth->execute( map $r->$_(), @$param )
1916 or die "Unexpected error executing statement $sql: ". $sth->errstr;
1917 $sth->fetchrow_arrayref->[0] || 0;
1926 # get all distinct tuples of (tax name, state, county, city, locationtaxid)
1927 # for taxes that have been charged
1928 # (state, county, city are from tax_rate_location, not from customer data)
1929 my @tax_and_location = qsearch({ table => 'cust_bill_pkg',
1931 hashref => { pkgpart => 0 },
1932 addl_from => $addl_from,
1933 extra_sql => $where,
1936 $count = scalar(@tax_and_location);
1937 foreach my $t ( @tax_and_location ) {
1940 if ( time - $min_sec > $last ) {
1941 $args{job}->update_statustext( int( 100 * $calculated / $count ).
1948 #my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
1949 my $label = join('~', map { $t->$_ } @params);
1950 $label = 'Tax'. $label if $label =~ /^~/;
1951 unless ( exists( $taxes{$label} ) ) {
1952 my ($baselabel, @trash) = split /~/, $label;
1954 $taxes{$label}->{'label'} = join(', ', split(/~/, $label) );
1955 $taxes{$label}->{'url_param'} =
1956 join(';', map { "$_=". uri_escape($t->$_) } @params);
1959 # " payby != 'COMP' ". # breaks the entire report under 4.x
1960 # # and unnecessary since COMP accounts don't
1961 # # get taxes calculated in the first place
1962 " ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
1963 "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
1968 "FROM cust_bill_pkg $addl_from $where AND $itemdesc_loc";
1970 my $sql = "SELECT SUM(amount) $taxwhere AND cust_bill_pkg.pkgnum = 0";
1972 my $x = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
1974 $taxes{$label}->{'tax'} += $x;
1977 "JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum)";
1979 "FROM cust_bill_pkg $addl_from $creditfrom $where AND $itemdesc_loc";
1981 $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
1982 " $creditwhere AND cust_bill_pkg.pkgnum = 0";
1984 my $y = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
1986 $taxes{$label}->{'credit'} += $y;
1988 unless ( exists( $taxes{$baselabel} ) ) {
1990 $basetaxes{$baselabel}->{'label'} = $baselabel;
1991 $basetaxes{$baselabel}->{'url_param'} = "itemdesc=$baselabel";
1992 $basetaxes{$baselabel}->{'base'} = 1;
1996 $basetaxes{$baselabel}->{'tax'} += $x;
1997 $basetaxes{$baselabel}->{'credit'} += $y;
2001 # calculate customer-exemption for this tax
2002 # calculate package-exemption for this tax
2003 # calculate monthly exemption (texas tax) for this tax
2004 # count up all the cust_tax_exempt_pkg records associated with
2005 # the actual line items.
2012 $args{job}->update_statustext( "0,Sorted" );
2018 foreach my $tax ( sort { $a cmp $b } keys %taxes ) {
2019 my ($base, @trash) = split '~', $tax;
2020 my $basetax = delete( $basetaxes{$base} );
2022 if ( $basetax->{tax} == $taxes{$tax}->{tax} ) {
2023 $taxes{$tax}->{base} = 1;
2025 push @taxes, $basetax;
2028 push @taxes, $taxes{$tax};
2035 'credit' => $credit,
2040 my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
2041 $dateagentlink .= ';agentnum='. $args{agentnum}
2042 if length($agentname);
2043 my $baselink = $args{p}. "search/cust_bill_pkg.cgi?vendortax=1;" .
2045 my $creditlink = $args{p}. "search/cust_credit_bill_pkg.html?$dateagentlink";
2047 print $report <<EOF;
2049 <% include("/elements/header.html", "$agentname Tax Report - ".
2051 ? time2str('%h %o %Y ', $args{beginning} )
2055 ( $args{ending} == 4294967295
2057 : time2str('%h %o %Y', $args{ending} )
2062 <% include('/elements/table-grid.html') %>
2065 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2066 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2067 <TH CLASS="grid" BGCOLOR="#cccccc">Tax invoiced</TH>
2068 <TH CLASS="grid" BGCOLOR="#cccccc"> </TH>
2069 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2070 <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
2074 my $bgcolor1 = '#eeeeee';
2075 my $bgcolor2 = '#ffffff';
2078 $count = scalar(@taxes);
2080 foreach my $tax ( @taxes ) {
2083 if ( time - $min_sec > $last ) {
2084 $args{job}->update_statustext( int( 100 * $calculated / $count ).
2091 if ( $bgcolor eq $bgcolor1 ) {
2092 $bgcolor = $bgcolor2;
2094 $bgcolor = $bgcolor1;
2098 if ( $tax->{'label'} ne 'Total' ) {
2099 $link = ';'. $tax->{'url_param'};
2102 print $report <<EOF;
2104 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"><% '$tax->{label}' %></TD>
2105 <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2106 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2107 <A HREF="<% '$baselink$link' %>;istax=1"><% '$money_char' %><% sprintf('%.2f', $tax->{'tax'} ) %></A>
2109 <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2110 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
2111 <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2112 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2113 <A HREF="<% '$creditlink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
2115 <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2120 print $report <<EOF;
2127 my $reportname = $report->filename;
2130 my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
2131 $reportname =~ s/^$dropstring//;
2133 my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname";
2134 die "<a href=$reporturl>view</a>\n";
2144 Mixing automatic and manual editing works poorly at present.
2146 Tax liability calculations take too long and arguably don't belong here.
2147 Tax liability report generation not entirely safe (escaped).
2151 L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>