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;
22 use FS::Record qw( qsearch qsearchs dbh dbdef );
25 use FS::cust_bill_pkg;
26 use FS::cust_tax_location;
27 use FS::tax_rate_location;
28 use FS::part_pkg_taxrate;
29 use FS::part_pkg_taxproduct;
31 use FS::Misc qw( csv_from_fixed );
35 @ISA = qw( FS::Record );
38 $me = '[FS::tax_rate]';
43 FS::tax_rate - Object methods for tax_rate objects
49 $record = new FS::tax_rate \%hash;
50 $record = new FS::tax_rate { 'column' => 'value' };
52 $error = $record->insert;
54 $error = $new_record->replace($old_record);
56 $error = $record->delete;
58 $error = $record->check;
62 An FS::tax_rate object represents a tax rate, defined by locale.
63 FS::tax_rate inherits from FS::Record. The following fields are
70 primary key (assigned automatically for new tax rates)
74 a geographic location code provided by a tax data vendor
82 a location code provided by a tax authority
86 a foreign key into FS::tax_class - the type of tax
87 referenced but FS::part_pkg_taxrate
90 the time after which the tax applies
98 second bracket percentage
102 the amount to which the tax applies (first bracket)
106 a cap on the amount of tax if a cap exists
110 percentage on out of jurisdiction purchases
114 second bracket percentage on out of jurisdiction purchases
118 one of the values in %tax_unittypes
122 amount of tax per unit
126 second bracket amount of tax per unit
130 the number of units to which the fee applies (first bracket)
134 the most units to which fees apply (first and second brackets)
138 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
142 if defined, printed on invoices instead of "Tax"
146 a value from %tax_authorities
150 a value from %tax_basetypes indicating the tax basis
154 a value from %tax_passtypes indicating how the tax should displayed to the customer
158 'Y', 'N', or blank indicating the tax can be passed to the customer
162 if 'Y', this tax does not apply to setup fees
166 if 'Y', this tax does not apply to recurring fees
170 if 'Y', has been manually edited
180 Creates a new tax rate. To add the tax rate to the database, see L<"insert">.
184 sub table { 'tax_rate'; }
188 Adds this tax rate to the database. If there is an error, returns the error,
189 otherwise returns false.
193 Deletes this tax rate from the database. If there is an error, returns the
194 error, otherwise returns false.
196 =item replace OLD_RECORD
198 Replaces the OLD_RECORD with this one in the database. If there is an error,
199 returns the error, otherwise returns false.
203 Checks all fields to make sure this is a valid tax rate. If there is an error,
204 returns the error, otherwise returns false. Called by the insert and replace
212 foreach (qw( taxbase taxmax )) {
213 $self->$_(0) unless $self->$_;
216 $self->ut_numbern('taxnum')
217 || $self->ut_text('geocode')
218 || $self->ut_textn('data_vendor')
219 || $self->ut_cch_textn('location')
220 || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
221 || $self->ut_snumbern('effective_date')
222 || $self->ut_float('tax')
223 || $self->ut_floatn('excessrate')
224 || $self->ut_money('taxbase')
225 || $self->ut_money('taxmax')
226 || $self->ut_floatn('usetax')
227 || $self->ut_floatn('useexcessrate')
228 || $self->ut_numbern('unittype')
229 || $self->ut_floatn('fee')
230 || $self->ut_floatn('excessfee')
231 || $self->ut_floatn('feemax')
232 || $self->ut_numbern('maxtype')
233 || $self->ut_textn('taxname')
234 || $self->ut_numbern('taxauth')
235 || $self->ut_numbern('basetype')
236 || $self->ut_numbern('passtype')
237 || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
238 || $self->ut_enum('setuptax', [ '', 'Y' ] )
239 || $self->ut_enum('recurtax', [ '', 'Y' ] )
240 || $self->ut_enum('inoutcity', [ '', 'I', 'O' ] )
241 || $self->ut_enum('inoutlocal', [ '', 'I', 'O' ] )
242 || $self->ut_enum('manual', [ '', 'Y' ] )
243 || $self->ut_enum('disabled', [ '', 'Y' ] )
244 || $self->SUPER::check
249 #ut_text / ut_textn w/ ` added cause now that's in the data
252 $self->getfield($field)
253 =~ /^([\wô \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>\`]*)$/
254 or return gettext('illegal_or_empty_text'). " $field: ".
255 $self->getfield($field);
256 $self->setfield($field,$1);
261 =item taxclass_description
263 Returns the human understandable value associated with the related
268 sub taxclass_description {
270 my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
271 $tax_class ? $tax_class->description : '';
276 Returns the human understandable value associated with the unittype column
280 %tax_unittypes = ( '0' => 'access line',
287 $tax_unittypes{$self->unittype};
292 Returns the human understandable value associated with the maxtype column.
296 # XXX these are non-functional, and most of them are horrible to implement
297 # in our current model
299 %tax_maxtypes = ( '0' => 'receipts per invoice',
300 '1' => 'receipts per item',
301 '2' => 'total utility charges per utility tax year',
302 '3' => 'total charges per utility tax year',
303 '4' => 'receipts per access line',
304 '7' => 'total utility charges per calendar year',
305 '9' => 'monthly receipts per location',
306 '10' => 'monthly receipts exceeds taxbase and total tax per month does not exceed maxtax', # wtf?
307 '11' => 'receipts/units per access line',
308 '14' => 'units per invoice',
309 '15' => 'units per month',
310 '18' => 'units per account',
315 $tax_maxtypes{$self->maxtype};
320 Returns the human understandable value associated with the basetype column
324 %tax_basetypes = ( '0' => 'sale price',
325 '1' => 'gross receipts',
326 '2' => 'sales taxable telecom revenue',
327 '3' => 'minutes carried',
328 '4' => 'minutes billed',
329 '5' => 'gross operating revenue',
330 '6' => 'access line',
332 '8' => 'gross revenue',
333 '9' => 'portion gross receipts attributable to interstate service',
334 '10' => 'access line',
335 '11' => 'gross profits',
336 '12' => 'tariff rate',
338 '15' => 'prior year gross receipts',
343 $tax_basetypes{$self->basetype};
348 Returns the human understandable value associated with the taxauth column
352 %tax_authorities = ( '0' => 'federal',
357 '5' => 'county administered by state',
358 '6' => 'city administered by state',
359 '7' => 'city administered by county',
360 '8' => 'local administered by state',
361 '9' => 'local administered by county',
366 $tax_authorities{$self->taxauth};
371 Returns the human understandable value associated with the passtype column
375 %tax_passtypes = ( '0' => 'separate tax line',
376 '1' => 'separate surcharge line',
377 '2' => 'surcharge not separated',
378 '3' => 'included in base rate',
383 $tax_passtypes{$self->passtype};
386 #Returns a listref of a name and an amount of tax calculated for the list
387 #of packages/amounts referenced by TAXABLES. If an error occurs, a message
388 #is returned as a scalar.
390 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
392 Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable
393 line items, and returns some number of new L<FS::cust_bill_pkg> objects
394 representing the tax on them under this tax rate. Each returned object
395 will correspond to a single input line item.
397 For accurate calculation of per-customer or per-location taxes, ALL items
398 appearing on the invoice MUST be passed to this method together.
400 Optionally, any of the L<FS::cust_bill_pkg> objects may be followed in the
401 array by a charge class: 'setup', 'recur', '' (for unclassified usage), or an
402 integer denoting an L<FS::usage_class> number. In this case, the tax will
403 only be charged on that portion of the line item.
405 Each returned object will have a pseudo-field,
406 "cust_bill_pkg_tax_rate_location", containing a single
407 L<FS::cust_bill_pkg_tax_rate_location> object. This will in turn
408 have a "taxable_cust_bill_pkg" pseudo-field linking it to one of the taxable
409 items. All of these links must be resolved as the objects are inserted.
411 If the tax is disabled, this method will return nothing. Be prepared for
414 In addition to calculating the tax for the line items, this will calculate
415 tax exemptions and attach them to the line items. I<Currently this only
416 supports customer exemptions.>
418 Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
419 objects belong to an invoice that hasn't been inserted yet.
421 The 'exemptions' option allowed in L<FS::cust_main_county::taxline> does
422 nothing here, since monthly exemptions aren't supported.
427 my( $self, $taxables, %opt) = @_;
428 $taxables = [ $taxables ] unless ref($taxables) eq 'ARRAY';
430 my $name = $self->taxname;
431 $name = 'Other surcharges'
432 if ($self->passtype == 2);
435 return unless @$taxables; # nothing to do
436 return if $self->disabled; # tax is disabled, skip it
437 return if $self->passflag eq 'N'; # tax can't be passed to the customer
438 # but should probably still appear on the liability report--create a
439 # cust_tax_exempt_pkg record for it?
441 # XXX a certain amount of false laziness with FS::cust_main_county
442 my $cust_bill = $taxables->[0]->cust_bill;
443 my $custnum = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
444 my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
446 die "unable to calculate taxes for an unknown customer\n";
449 my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum
450 or die "no tax_rate_location linked to tax_rate #".$self->taxnum."\n";
452 warn "calculating taxes for ". $self->taxnum. " on ".
453 join (",", map { $_->pkgnum } @$taxables)
456 my $maxtype = $self->maxtype || 0;
457 if ($maxtype != 0 && $maxtype != 1
458 && $maxtype != 14 && $maxtype != 15
459 && $maxtype != 18 # sigh
461 return $self->_fatal_or_null( 'tax with "'.
462 $self->maxtype_name. '" threshold'
464 } # I don't know why, it's not like there are maxtypes that we DO support
466 # we treat gross revenue as gross receipts and expect the tax data
467 # to DTRT (i.e. tax on tax rules)
468 if ($self->basetype != 0 && $self->basetype != 1 &&
469 $self->basetype != 5 && $self->basetype != 6 &&
470 $self->basetype != 7 && $self->basetype != 8 &&
471 $self->basetype != 14
474 $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
478 my %seen; # locationnum or pkgnum => 1
480 my $taxable_cents = 0;
481 my $taxable_units = 0;
485 my $cust_bill_pkg = shift @$taxables;
487 if ( defined($taxables->[0]) and !ref($taxables->[0]) ) {
488 $class = shift @$taxables;
491 my %usage_map = map { $_ => $cust_bill_pkg->usage($_) }
492 $cust_bill_pkg->usage_classes;
493 my $usage_total = sum( values(%usage_map), 0 );
495 # determine if the item has exemptions that apply to this tax def
496 my @exemptions = grep { $_->taxnum == $self->taxnum }
497 @{ $cust_bill_pkg->cust_tax_exempt_pkg };
499 if ( $self->tax > 0 ) {
501 my $taxable_charged = 0;
502 if ($class eq 'all') {
503 $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur;
504 } elsif ($class eq 'setup') {
505 $taxable_charged = $cust_bill_pkg->setup;
506 } elsif ($class eq 'recur') {
507 $taxable_charged = $cust_bill_pkg->recur - $usage_total;
509 $taxable_charged = $usage_map{$class} || 0;
512 foreach my $ex (@exemptions) {
513 # the only cases where the exemption doesn't apply:
514 # if it's a setup exemption and $class is not 'setup' or 'all'
515 # if it's a recur exemption and $class is 'setup'
516 if ( ( $ex->exempt_recur and $class eq 'setup' )
517 or ( $ex->exempt_setup and $class ne 'setup' and $class ne 'all' )
522 $taxable_charged -= $ex->amount;
524 # cust_main_county handles monthly capped exemptions; this doesn't.
526 # $taxable_charged can also be less than zero at this point
527 # (recur exemption + usage class breakdown); treat that as zero.
528 next if $taxable_charged <= 0;
530 # yeah, some false laziness with cust_main_county
531 my $this_tax_cents = int(100 * $taxable_charged * $self->tax);
532 my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({
533 'taxnum' => $self->taxnum,
534 'taxtype' => ref($self),
535 'cents' => $this_tax_cents, # not a real field
536 'locationtaxid' => $self->location, # fundamentally silly
537 'taxable_cust_bill_pkg' => $cust_bill_pkg,
538 'taxratelocationnum' => $taxratelocationnum,
539 'taxclass' => $class,
541 push @tax_locations, $tax_location;
543 $taxable_cents += 100 * $taxable_charged;
544 $tax_cents += $this_tax_cents;
546 } elsif ( $self->fee > 0 ) {
547 # most CCH taxes are this type, because nearly every county has a 911
551 # since we don't support partial exemptions (except setup/recur),
552 # if there's an exemption that applies to this package and taxrate,
553 # don't charge ANY per-unit fees
556 # don't apply fees to usage classes (maybe if we ever get per-minute
558 next unless $class eq 'setup'
562 if ( $self->unittype == 0 ) {
563 if ( !$seen{$cust_bill_pkg->pkgnum} ) {
565 $units = $cust_bill_pkg->units;
566 $seen{$cust_bill_pkg->pkgnum} = 1;
567 } # else it's been seen, leave it at zero units
569 } elsif ($self->unittype == 1) { # per minute
570 # STILL not supported...fortunately these only exist if you happen
571 # to be in Idaho or Little Rock, Arkansas
573 # though a voip_cdr package could easily report minutes of usage...
574 return $self->_fatal_or_null( 'fee with minute unit type' );
576 } elsif ( $self->unittype == 2 ) {
579 $units = 1 unless $seen{$cust_bill_pkg->tax_locationnum};
580 $seen{$cust_bill_pkg->tax_locationnum} = 1;
583 # Unittype 19 is used for prepaid wireless E911 charges in many states.
584 # Apparently "per retail purchase", which for us would mean per invoice.
585 # Unittype 20 is used for some 911 surcharges and I have no idea what
587 return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
589 my $this_tax_cents = int($units * $self->fee * 100);
590 my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({
591 'taxnum' => $self->taxnum,
592 'taxtype' => ref($self),
593 'cents' => $this_tax_cents,
594 'locationtaxid' => $self->location,
595 'taxable_cust_bill_pkg' => $cust_bill_pkg,
596 'taxratelocationnum' => $taxratelocationnum,
598 push @tax_locations, $tax_location;
600 $taxable_units += $units;
601 $tax_cents += $this_tax_cents;
604 } # foreach $cust_bill_pkg
606 # check bracket maxima; throw an error if we've gone over, because
607 # we don't really implement them
609 if ( ($self->taxmax > 0 and $taxable_cents > $self->taxmax*100 ) or
610 ($self->feemax > 0 and $taxable_units > $self->feemax) ) {
612 # (why not just cap taxable_charged/units at the taxmax/feemax? because
613 # it's way more complicated than that. this won't even catch every case
614 # where a bracket maximum should apply.)
615 return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum );
618 # round and distribute
619 my $total_tax_cents = sprintf('%.0f',
620 ($taxable_cents * $self->tax) + ($taxable_units * $self->fee * 100)
622 my $extra_cents = sprintf('%.0f', $total_tax_cents - $tax_cents);
623 $tax_cents += $extra_cents;
625 foreach (@tax_locations) { # can never require more than a single pass, yes?
626 my $cents = $_->get('cents');
627 if ( $extra_cents > 0 ) {
631 $_->set('amount', sprintf('%.2f', $cents/100));
634 # just transform each CBPTRL record into a tax line item.
635 # calculate_taxes will consolidate them, but before that happens we have
636 # to do tax on tax calculation.
638 foreach (@tax_locations) {
639 next if $_->amount == 0;
640 my $tax_item = FS::cust_bill_pkg->new({
643 'setup' => $_->amount,
644 'sdate' => '', # $_->sdate?
647 'cust_bill_pkg_tax_rate_location' => [ $_ ],
649 $_->set('tax_cust_bill_pkg' => $tax_item);
650 push @tax_items, $tax_item;
657 my ($self, $error) = @_;
659 $DB::single = 1; # not a mistake
661 my $conf = new FS::Conf;
663 $error = "can't yet handle ". $error;
664 my $name = $self->taxname;
665 $name = 'Other surcharges'
666 if ($self->passtype == 2);
668 if ($conf->exists('ignore_incalculable_taxes')) {
669 warn "WARNING: $error; billing anyway per ignore_incalculable_taxes conf\n";
670 return { name => $name, amount => 0 };
672 return "fatal: $error";
676 =item tax_on_tax CUST_LOCATION
678 Returns a list of taxes which are candidates for taxing taxes for the
679 given service location (see L<FS::cust_location>)
687 my $cust_location = shift;
689 warn "looking up taxes on tax ". $self->taxnum. " for customer ".
690 $cust_location->custnum
693 my $geocode = $cust_location->geocode($self->data_vendor);
697 my $extra_sql = ' AND ('.
698 join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
703 my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
704 my $select = 'DISTINCT ON(taxclassnum) *';
706 # should qsearch preface columns with the table to facilitate joins?
707 my @taxclassnums = map { $_->taxclassnum }
708 qsearch( { 'table' => 'part_pkg_taxrate',
710 'hashref' => { 'data_vendor' => $self->data_vendor,
711 'taxclassnumtaxed' => $self->taxclassnum,
713 'extra_sql' => $extra_sql,
714 'order_by' => $order_by,
717 return () unless @taxclassnums;
720 "AND (". join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
722 qsearch({ 'table' => 'tax_rate',
723 'hashref' => { 'geocode' => $geocode, },
724 'extra_sql' => $extra_sql,
729 =item tax_rate_location
731 Returns an object representing the location associated with this tax
732 (see L<FS::tax_rate_location>)
736 sub tax_rate_location {
739 qsearchs({ 'table' => 'tax_rate_location',
740 'hashref' => { 'data_vendor' => $self->data_vendor,
741 'geocode' => $self->geocode,
745 new FS::tax_rate_location;
759 sub _progressbar_foo {
764 my ($param, $job) = @_;
766 my $fh = $param->{filehandle};
767 my $format = $param->{'format'};
775 my @column_lengths = ();
776 my @column_callbacks = ();
777 if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
778 $format =~ s/-fixed//;
779 my $date_format = sub { my $r='';
780 /^(\d{4})(\d{2})(\d{2})$/ && ($r="$3/$2/$1");
783 my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
784 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 );
785 push @column_lengths, 1 if $format eq 'cch-update';
786 push @column_callbacks, $trim foreach (@column_lengths); # 5, 6, 15, 17 esp
787 $column_callbacks[8] = $date_format;
791 my ( $count, $last, $min_sec ) = _progressbar_foo();
792 if ( $job || scalar(@column_callbacks) ) {
794 csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
795 return $error if $error;
799 if ( $format eq 'cch' || $format eq 'cch-update' ) {
800 #false laziness w/below (sub _perform_cch_diff)
801 @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
802 excessrate effective_date taxauth taxtype taxcat taxname
803 usetax useexcessrate fee unittype feemax maxtype passflag
805 push @fields, 'actionflag' if $format eq 'cch-update';
810 $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
811 $hash->{'data_vendor'} ='cch';
812 my $parser = new DateTime::Format::Strptime( pattern => "%m/%d/%Y",
813 time_zone => 'floating',
815 my $dt = $parser->parse_datetime( $hash->{'effective_date'} );
816 $hash->{'effective_date'} = $dt ? $dt->epoch : '';
818 $hash->{$_} =~ s/\s//g foreach qw( inoutcity inoutlocal ) ;
819 $hash->{$_} = sprintf("%.2f", $hash->{$_}) foreach qw( taxbase taxmax );
822 join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
824 my %tax_class = ( 'data_vendor' => 'cch',
825 'taxclass' => $taxclassid,
828 my $tax_class = qsearchs( 'tax_class', \%tax_class );
829 return "Error updating tax rate: no tax class $taxclassid"
832 $hash->{'taxclassnum'} = $tax_class->taxclassnum;
834 foreach (qw( taxtype taxcat )) {
838 my %passflagmap = ( '0' => '',
842 $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
843 if exists $passflagmap{$hash->{'passflag'}};
845 foreach (keys %$hash) {
846 $hash->{$_} = substr($hash->{$_}, 0, 80)
847 if length($hash->{$_}) > 80;
850 my $actionflag = delete($hash->{'actionflag'});
852 $hash->{'taxname'} =~ s/`/'/g;
853 $hash->{'taxname'} =~ s|\\|/|g;
855 return '' if $format eq 'cch'; # but not cch-update
857 if ($actionflag eq 'I') {
858 $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
859 }elsif ($actionflag eq 'D') {
860 $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
862 return "Unexpected action flag: ". $hash->{'actionflag'};
865 delete($hash->{$_}) for keys %$hash;
871 } elsif ( $format eq 'extended' ) {
872 die "unimplemented\n";
876 die "unknown format $format";
879 my $csv = new Text::CSV_XS;
883 local $SIG{HUP} = 'IGNORE';
884 local $SIG{INT} = 'IGNORE';
885 local $SIG{QUIT} = 'IGNORE';
886 local $SIG{TERM} = 'IGNORE';
887 local $SIG{TSTP} = 'IGNORE';
888 local $SIG{PIPE} = 'IGNORE';
890 my $oldAutoCommit = $FS::UID::AutoCommit;
891 local $FS::UID::AutoCommit = 0;
894 while ( defined($line=<$fh>) ) {
895 $csv->parse($line) or do {
896 $dbh->rollback if $oldAutoCommit;
897 return "can't parse: ". $csv->error_input();
900 if ( $job ) { # progress bar
901 if ( time - $min_sec > $last ) {
902 my $error = $job->update_statustext(
903 int( 100 * $imported / $count ). ",Importing tax rates"
906 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
913 my @columns = $csv->fields();
915 my %tax_rate = ( 'data_vendor' => $format );
916 foreach my $field ( @fields ) {
917 $tax_rate{$field} = shift @columns;
920 if ( scalar( @columns ) ) {
921 $dbh->rollback if $oldAutoCommit;
922 return "Unexpected trailing columns in line (wrong format?) importing tax_rate: $line";
925 my $error = &{$hook}(\%tax_rate);
927 $dbh->rollback if $oldAutoCommit;
931 if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
933 my $tax_rate = new FS::tax_rate( \%tax_rate );
934 $error = $tax_rate->insert;
937 $dbh->rollback if $oldAutoCommit;
938 return "can't insert tax_rate for $line: $error";
947 my @replace = grep { exists($delete{$_}) } keys %insert;
949 if ( $job ) { # progress bar
950 if ( time - $min_sec > $last ) {
951 my $error = $job->update_statustext(
952 int( 100 * $imported / $count ). ",Importing tax rates"
955 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
962 my $old = qsearchs( 'tax_rate', $delete{$_} );
966 my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => '' });
967 $new->taxnum($old->taxnum);
968 my $error = $new->replace($old);
971 $dbh->rollback if $oldAutoCommit;
972 my $hashref = $insert{$_};
973 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
974 return "can't replace tax_rate for $line: $error";
981 $old = delete $delete{$_};
982 warn "WARNING: can't find tax_rate to replace (inserting instead and continuing) for: ".
983 #join(" ", map { "$_ => ". $old->{$_} } @fields);
984 join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
990 for (grep { !exists($delete{$_}) } keys %insert) {
991 if ( $job ) { # progress bar
992 if ( time - $min_sec > $last ) {
993 my $error = $job->update_statustext(
994 int( 100 * $imported / $count ). ",Importing tax rates"
997 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1004 my $tax_rate = new FS::tax_rate( $insert{$_} );
1005 my $error = $tax_rate->insert;
1008 $dbh->rollback if $oldAutoCommit;
1009 my $hashref = $insert{$_};
1010 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
1011 return "can't insert tax_rate for $line: $error";
1017 for (grep { !exists($insert{$_}) } keys %delete) {
1018 if ( $job ) { # progress bar
1019 if ( time - $min_sec > $last ) {
1020 my $error = $job->update_statustext(
1021 int( 100 * $imported / $count ). ",Importing tax rates"
1024 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1031 my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
1033 $dbh->rollback if $oldAutoCommit;
1034 $tax_rate = $delete{$_};
1035 warn "WARNING: can't find tax_rate to delete for: ".
1036 join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) ).
1039 my $error = $tax_rate->delete; # XXX we really should not do this
1040 # (it orphans CBPTRL records)
1043 $dbh->rollback if $oldAutoCommit;
1044 my $hashref = $delete{$_};
1045 $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
1046 return "can't delete tax_rate for $line: $error";
1053 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1055 return "Empty file!" unless ($imported || $format eq 'cch-update');
1061 =item process_batch_import
1063 Load a batch import as a queued JSRPC job
1067 sub process_batch_import {
1070 my $oldAutoCommit = $FS::UID::AutoCommit;
1071 local $FS::UID::AutoCommit = 0;
1074 my $param = thaw(decode_base64(shift));
1075 my $args = '$job, encode_base64( nfreeze( $param ) )';
1077 my $method = '_perform_batch_import';
1078 if ( $param->{reload} ) {
1079 $method = 'process_batch_reload';
1082 eval "$method($args);";
1084 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1089 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1092 sub _perform_batch_import {
1095 my $param = thaw(decode_base64(shift));
1096 my $format = $param->{'format'}; #well... this is all cch specific
1098 my $files = $param->{'uploaded_files'}
1099 or die "No files provided.";
1101 my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
1104 if ( $format eq 'cch' || $format eq 'cch-fixed'
1105 || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
1108 my $oldAutoCommit = $FS::UID::AutoCommit;
1109 local $FS::UID::AutoCommit = 0;
1112 my @insert_list = ();
1113 my @delete_list = ();
1114 my @predelete_list = ();
1115 my $insertname = '';
1116 my $deletename = '';
1117 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
1119 my @list = ( 'GEOCODE', \&FS::tax_rate_location::batch_import,
1120 'CODE', \&FS::tax_class::batch_import,
1121 'PLUS4', \&FS::cust_tax_location::batch_import,
1122 'ZIP', \&FS::cust_tax_location::batch_import,
1123 'TXMATRIX', \&FS::part_pkg_taxrate::batch_import,
1124 'DETAIL', \&FS::tax_rate::batch_import,
1126 while( scalar(@list) ) {
1127 my ( $name, $import_sub ) = splice( @list, 0, 2 );
1128 my $file = lc($name). 'file';
1130 unless ($files{$file}) {
1131 #$error = "No $name supplied";
1134 next if $name eq 'DETAIL' && $format =~ /update/;
1136 my $filename = "$dir/". $files{$file};
1138 if ( $format =~ /update/ ) {
1140 ( $error, $insertname, $deletename ) =
1141 _perform_cch_insert_delete_split( $name, $filename, $dir, $format )
1145 unlink $filename or warn "Can't delete $filename: $!"
1146 unless $keep_cch_files;
1147 push @insert_list, $name, $insertname, $import_sub, $format;
1148 if ( $name eq 'GEOCODE' || $name eq 'CODE' ) { #handle this whole ordering issue better
1149 unshift @predelete_list, $name, $deletename, $import_sub, $format;
1151 unshift @delete_list, $name, $deletename, $import_sub, $format;
1156 push @insert_list, $name, $filename, $import_sub, $format;
1163 'DETAIL', "$dir/".$files{detailfile}, \&FS::tax_rate::batch_import, $format
1164 if $format =~ /update/;
1166 my %addl_param = ();
1167 if ( $param->{'delete_only'} ) {
1168 $addl_param{'delete_only'} = $param->{'delete_only'};
1172 $error ||= _perform_cch_tax_import( $job,
1173 [ @predelete_list ],
1180 @list = ( @predelete_list, @insert_list, @delete_list );
1181 while( !$keep_cch_files && scalar(@list) ) {
1182 my ( undef, $file, undef, undef ) = splice( @list, 0, 4 );
1183 unlink $file or warn "Can't delete $file: $!";
1187 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1190 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1194 die "Unknown format: $format";
1200 sub _perform_cch_tax_import {
1201 my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
1205 foreach my $list ($predelete_list, $insert_list, $delete_list) {
1206 while( scalar(@$list) ) {
1207 my ( $name, $file, $method, $format ) = splice( @$list, 0, 4 );
1208 my $fmt = "$format-update";
1209 $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
1210 open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
1211 my $param = { 'filehandle' => $fh,
1215 $error ||= &{$method}($param, $job);
1223 sub _perform_cch_insert_delete_split {
1224 my ($name, $filename, $dir, $format) = @_;
1228 open my $fh, "< $filename"
1229 or $error ||= "Can't open $name file $filename: $!";
1231 my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
1234 ) or die "can't open temp file: $!\n";
1235 my $insertname = $ifh->filename;
1237 my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
1240 ) or die "can't open temp file: $!\n";
1241 my $deletename = $dfh->filename;
1243 my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
1244 my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
1247 $handle = $ifh if $_ =~ /$insert_pattern/;
1248 $handle = $dfh if $_ =~ /$delete_pattern/;
1250 $error = "bad input line: $_" unless $handle;
1259 return ($error, $insertname, $deletename);
1262 sub _perform_cch_diff {
1263 my ($name, $newdir, $olddir) = @_;
1268 open my $oldcsvfh, "$olddir/$name.txt"
1269 or die "failed to open $olddir/$name.txt: $!\n";
1271 while(<$oldcsvfh>) {
1278 open my $newcsvfh, "$newdir/$name.txt"
1279 or die "failed to open $newdir/$name.txt: $!\n";
1281 my $dfh = new File::Temp( TEMPLATE => "$name.diff.XXXXXXXX",
1284 ) or die "can't open temp file: $!\n";
1285 my $diffname = $dfh->filename;
1287 while(<$newcsvfh>) {
1289 if (exists($oldlines{$_})) {
1292 print $dfh $_, ',"I"', "\n";
1297 #false laziness w/above (sub batch_import)
1298 my @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
1299 excessrate effective_date taxauth taxtype taxcat taxname
1300 usetax useexcessrate fee unittype feemax maxtype passflag
1301 passtype basetype );
1302 my $numfields = scalar(@fields);
1304 my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1306 for my $line (grep $oldlines{$_}, keys %oldlines) {
1308 $csv->parse($line) or do {
1309 #$dbh->rollback if $oldAutoCommit;
1310 die "can't parse: ". $csv->error_input();
1312 my @columns = $csv->fields();
1314 $csv->combine( splice(@columns, 0, $numfields) );
1316 print $dfh $csv->string, ',"D"', "\n";
1324 sub _cch_fetch_and_unzip {
1325 my ( $job, $urls, $secret, $dir ) = @_;
1327 my $ua = new LWP::UserAgent;
1328 foreach my $url (split ',', $urls) {
1329 my @name = split '/', $url; #somewhat restrictive
1330 my $name = pop @name;
1331 $name =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1334 open my $taxfh, ">$dir/$name" or die "Can't open $dir/$name: $!\n";
1336 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1337 my $res = $ua->request(
1338 new HTTP::Request( GET => $url ),
1340 print $taxfh $_[0] or die "Can't write to $dir/$name: $!\n";
1341 my $content_length = $_[1]->content_length;
1342 $imported += length($_[0]);
1343 if ( time - $min_sec > $last ) {
1344 my $error = $job->update_statustext(
1345 ($content_length ? int(100 * $imported/$content_length) : 0 ).
1346 ",Downloading data from CCH"
1348 die $error if $error;
1353 die "download of $url failed: ". $res->status_line
1354 unless $res->is_success;
1357 my $error = $job->update_statustext( "0,Unpacking data" );
1358 die $error if $error;
1359 $secret =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1361 system('unzip', "-P", $secret, "-d", "$dir", "$dir/$name") == 0
1362 or die "unzip -P $secret -d $dir $dir/$name failed";
1363 #unlink "$dir/$name";
1367 sub _cch_extract_csv_from_dbf {
1368 my ( $job, $dir, $name ) = @_;
1373 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1374 my $error = $job->update_statustext( "0,Unpacking $name" );
1375 die $error if $error;
1376 warn "opening $dir.new/$name.dbf\n" if $DEBUG;
1377 my $table = new XBase 'name' => "$dir.new/$name.dbf";
1378 die "failed to access $dir.new/$name.dbf: ". XBase->errstr
1379 unless defined($table);
1380 my $count = $table->last_record; # approximately;
1381 open my $csvfh, ">$dir.new/$name.txt"
1382 or die "failed to open $dir.new/$name.txt: $!\n";
1384 my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1385 my @fields = $table->field_names;
1386 my $cursor = $table->prepare_select;
1388 sub { my $date = shift;
1389 $date =~ /^(\d{4})(\d{2})(\d{2})$/ && ($date = "$2/$3/$1");
1392 while (my $row = $cursor->fetch_hashref) {
1393 $csv->combine( map { my $type = $table->field_type($_);
1395 &{$format_date}($row->{$_}) ;
1396 } elsif ($type eq 'N' && $row->{$_} =~ /e-/i ) {
1397 sprintf('%.8f', $row->{$_}); #db row is numeric(14,8)
1404 print $csvfh $csv->string, "\n";
1406 if ( time - $min_sec > $last ) {
1407 my $error = $job->update_statustext(
1408 int(100 * $imported/$count). ",Unpacking $name"
1410 die $error if $error;
1418 sub _remember_disabled_taxes {
1419 my ( $job, $format, $disabled_tax_rate ) = @_;
1423 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1425 my @items = qsearch( { table => 'tax_rate',
1426 hashref => { disabled => 'Y',
1427 data_vendor => $format,
1429 select => 'geocode, taxclassnum',
1432 my $count = scalar(@items);
1433 foreach my $tax_rate ( @items ) {
1434 if ( time - $min_sec > $last ) {
1435 $job->update_statustext(
1436 int( 100 * $imported / $count ). ",Remembering disabled taxes"
1442 qsearchs( 'tax_class', { taxclassnum => $tax_rate->taxclassnum } );
1443 unless ( $tax_class ) {
1444 warn "failed to find tax_class ". $tax_rate->taxclassnum;
1447 $disabled_tax_rate->{$tax_rate->geocode. ':'. $tax_class->taxclass} = 1;
1451 sub _remember_tax_products {
1452 my ( $job, $format, $taxproduct ) = @_;
1454 # XXX FIXME this loop only works when cch is the only data provider
1456 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1458 my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
1459 "0 < ( SELECT count(*) from part_pkg_option WHERE ".
1460 " part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
1461 " optionname LIKE 'usage_taxproductnum_%' AND ".
1462 " optionvalue != '' )";
1463 my @items = qsearch( { table => 'part_pkg',
1464 select => 'DISTINCT pkgpart,taxproductnum',
1466 extra_sql => $extra_sql,
1469 my $count = scalar(@items);
1470 foreach my $part_pkg ( @items ) {
1471 if ( time - $min_sec > $last ) {
1472 $job->update_statustext(
1473 int( 100 * $imported / $count ). ",Remembering tax products"
1478 warn "working with package part ". $part_pkg->pkgpart.
1479 "which has a taxproductnum of ". $part_pkg->taxproductnum. "\n" if $DEBUG;
1480 my $part_pkg_taxproduct = $part_pkg->taxproduct('');
1481 $taxproduct->{$part_pkg->pkgpart}->{''} = $part_pkg_taxproduct->taxproduct
1482 if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1484 foreach my $option ( $part_pkg->part_pkg_option ) {
1485 next unless $option->optionname =~ /^usage_taxproductnum_(\w+)$/;
1488 $part_pkg_taxproduct = $part_pkg->taxproduct($class);
1489 $taxproduct->{$part_pkg->pkgpart}->{$class} =
1490 $part_pkg_taxproduct->taxproduct
1491 if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1496 sub _restore_remembered_tax_products {
1497 my ( $job, $format, $taxproduct ) = @_;
1501 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1502 my $count = scalar(keys %$taxproduct);
1503 foreach my $pkgpart ( keys %$taxproduct ) {
1504 warn "restoring taxproductnums on pkgpart $pkgpart\n" if $DEBUG;
1505 if ( time - $min_sec > $last ) {
1506 $job->update_statustext(
1507 int( 100 * $imported / $count ). ",Restoring tax products"
1513 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkgpart } );
1514 unless ( $part_pkg ) {
1515 return "somehow failed to find part_pkg with pkgpart $pkgpart!\n";
1518 my %options = $part_pkg->options;
1519 my %pkg_svc = map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc;
1520 my $primary_svc = $part_pkg->svcpart;
1521 my $new = new FS::part_pkg { $part_pkg->hash };
1523 foreach my $class ( keys %{ $taxproduct->{$pkgpart} } ) {
1524 warn "working with class '$class'\n" if $DEBUG;
1525 my $part_pkg_taxproduct =
1526 qsearchs( 'part_pkg_taxproduct',
1527 { taxproduct => $taxproduct->{$pkgpart}->{$class},
1528 data_vendor => $format,
1532 unless ( $part_pkg_taxproduct ) {
1533 return "failed to find part_pkg_taxproduct (".
1534 $taxproduct->{$pkgpart}->{$class}. ") for pkgpart $pkgpart\n";
1537 if ( $class eq '' ) {
1538 $new->taxproductnum($part_pkg_taxproduct->taxproductnum);
1542 $options{"usage_taxproductnum_$class"} =
1543 $part_pkg_taxproduct->taxproductnum;
1547 my $error = $new->replace( $part_pkg,
1548 'pkg_svc' => \%pkg_svc,
1549 'primary_svc' => $primary_svc,
1550 'options' => \%options,
1553 return $error if $error;
1560 sub _restore_remembered_disabled_taxes {
1561 my ( $job, $format, $disabled_tax_rate ) = @_;
1563 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1564 my $count = scalar(keys %$disabled_tax_rate);
1565 foreach my $key (keys %$disabled_tax_rate) {
1566 if ( time - $min_sec > $last ) {
1567 $job->update_statustext(
1568 int( 100 * $imported / $count ). ",Disabling tax rates"
1573 my ($geocode,$taxclass) = split /:/, $key, 2;
1574 my @tax_class = qsearch( 'tax_class', { data_vendor => $format,
1575 taxclass => $taxclass,
1577 return "found multiple tax_class records for format $format class $taxclass"
1578 if scalar(@tax_class) > 1;
1580 unless (scalar(@tax_class)) {
1581 warn "no tax_class for format $format class $taxclass\n";
1586 qsearch('tax_rate', { data_vendor => $format,
1587 geocode => $geocode,
1588 taxclassnum => $tax_class[0]->taxclassnum,
1592 if (scalar(@tax_rate) > 1) {
1593 return "found multiple tax_rate records for format $format geocode ".
1594 "$geocode and taxclass $taxclass ( taxclassnum ".
1595 $tax_class[0]->taxclassnum. " )";
1598 if (scalar(@tax_rate)) {
1599 $tax_rate[0]->disabled('Y');
1600 my $error = $tax_rate[0]->replace;
1601 return $error if $error;
1606 sub _remove_old_tax_data {
1607 my ( $job, $format ) = @_;
1610 my $error = $job->update_statustext( "0,Removing old tax data" );
1611 die $error if $error;
1613 my $sql = "UPDATE public.tax_rate_location SET disabled='Y' ".
1614 "WHERE data_vendor = ". $dbh->quote($format);
1615 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1618 tax_rate part_pkg_taxrate part_pkg_taxproduct tax_class cust_tax_location
1620 foreach my $table ( @table ) {
1621 $sql = "DELETE FROM public.$table WHERE data_vendor = ".
1622 $dbh->quote($format);
1623 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1626 if ( $format eq 'cch' ) {
1627 $sql = "DELETE FROM public.cust_tax_location WHERE data_vendor = ".
1628 $dbh->quote("$format-zip");
1629 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1635 sub _create_temporary_tables {
1636 my ( $job, $format ) = @_;
1639 my $error = $job->update_statustext( "0,Creating temporary tables" );
1640 die $error if $error;
1642 my @table = qw( tax_rate
1649 foreach my $table ( @table ) {
1651 "CREATE TEMPORARY TABLE $table ( LIKE $table INCLUDING DEFAULTS )";
1652 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1658 sub _copy_from_temp {
1659 my ( $job, $format ) = @_;
1662 my $error = $job->update_statustext( "0,Making permanent" );
1663 die $error if $error;
1665 my @table = qw( tax_rate
1672 foreach my $table ( @table ) {
1674 "INSERT INTO public.$table SELECT * from $table";
1675 $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1681 =item process_download_and_reload
1683 Download and process a tax update as a queued JSRPC job after wiping the
1684 existing wipable tax data.
1688 sub process_download_and_reload {
1689 _process_reload('process_download_and_update', @_);
1693 =item process_batch_reload
1695 Load and process a tax update from the provided files as a queued JSRPC job
1696 after wiping the existing wipable tax data.
1700 sub process_batch_reload {
1701 _process_reload('_perform_batch_import', @_);
1705 sub _process_reload {
1706 my ( $method, $job ) = ( shift, shift );
1708 my $param = thaw(decode_base64($_[0]));
1709 my $format = $param->{'format'}; #well... this is all cch specific
1711 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1713 if ( $job ) { # progress bar
1714 my $error = $job->update_statustext( 0 );
1715 die $error if $error;
1718 my $oldAutoCommit = $FS::UID::AutoCommit;
1719 local $FS::UID::AutoCommit = 0;
1724 "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
1725 "USING (taxclassnum) WHERE data_vendor = '$format'";
1726 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1728 or die "Unexpected error executing statement $sql: ". $sth->errstr;
1729 die "Don't (yet) know how to handle part_pkg_taxoverride records."
1730 if $sth->fetchrow_arrayref->[0];
1732 # really should get a table EXCLUSIVE lock here
1734 #remember disabled taxes
1735 my %disabled_tax_rate = ();
1736 $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
1738 #remember tax products
1739 my %taxproduct = ();
1740 $error ||= _remember_tax_products( $job, $format, \%taxproduct );
1743 $error ||= _create_temporary_tables( $job, $format );
1747 my $args = '$job, @_';
1748 eval "$method($args);";
1752 #restore taxproducts
1753 $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
1757 _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
1759 #wipe out the old data
1760 $error ||= _remove_old_tax_data( $job, $format );
1763 $error ||= _copy_from_temp( $job, $format );
1766 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1771 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1775 =item process_download_and_update
1777 Download and process a tax update as a queued JSRPC job
1781 sub process_download_and_update {
1784 my $param = thaw(decode_base64(shift));
1785 my $format = $param->{'format'}; #well... this is all cch specific
1787 my ( $imported, $last, $min_sec ) = _progressbar_foo();
1789 if ( $job ) { # progress bar
1790 my $error = $job->update_statustext( 0);
1791 die $error if $error;
1794 my $cache_dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
1795 my $dir = $cache_dir. 'taxdata';
1797 mkdir $dir or die "can't create $dir: $!\n";
1800 if ($format eq 'cch') {
1802 my @namelist = qw( code detail geocode plus4 txmatrix zip );
1804 my $conf = new FS::Conf;
1805 die "direct download of tax data not enabled\n"
1806 unless $conf->exists('taxdatadirectdownload');
1807 my ( $urls, $username, $secret, $states ) =
1808 $conf->config('taxdatadirectdownload');
1809 die "No tax download URL provided. ".
1810 "Did you set the taxdatadirectdownload configuration value?\n"
1818 # really should get a table EXCLUSIVE lock here
1819 # check if initial import or update
1821 # relying on mkdir "$dir.new" as a mutex
1823 my $sql = "SELECT count(*) from tax_rate WHERE data_vendor='$format'";
1824 my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1825 $sth->execute() or die $sth->errstr;
1826 my $update = $sth->fetchrow_arrayref->[0];
1828 # create cache and/or rotate old tax data
1833 opendir(my $dirh, "$dir.9") or die "failed to open $dir.9: $!\n";
1834 foreach my $file (readdir($dirh)) {
1835 unlink "$dir.9/$file" if (-f "$dir.9/$file");
1841 for (8, 7, 6, 5, 4, 3, 2, 1) {
1842 if ( -e "$dir.$_" ) {
1843 rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
1846 rename "$dir", "$dir.1" or die "can't rename $dir: $!\n";
1850 die "can't find previous tax data\n" if $update;
1854 mkdir "$dir.new" or die "can't create $dir.new: $!\n";
1856 # fetch and unpack the zip files
1858 _cch_fetch_and_unzip( $job, $urls, $secret, "$dir.new" );
1860 # extract csv files from the dbf files
1862 foreach my $name ( @namelist ) {
1863 _cch_extract_csv_from_dbf( $job, $dir, $name );
1866 # generate the diff files
1869 foreach my $name ( @namelist ) {
1870 my $difffile = "$dir.new/$name.txt";
1872 my $error = $job->update_statustext( "0,Comparing to previous $name" );
1873 die $error if $error;
1874 warn "processing $dir.new/$name.txt\n" if $DEBUG;
1875 my $olddir = $update ? "$dir.1" : "";
1876 $difffile = _perform_cch_diff( $name, "$dir.new", $olddir );
1878 $difffile =~ s/^$cache_dir//;
1879 push @list, "${name}file:$difffile";
1882 # perform the import
1883 local $keep_cch_files = 1;
1884 $param->{uploaded_files} = join( ',', @list );
1885 $param->{format} .= '-update' if $update;
1887 _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
1889 rename "$dir.new", "$dir"
1890 or die "cch tax update processed, but can't rename $dir.new: $!\n";
1893 die "Unknown format: $format";
1897 =item browse_queries PARAMS
1899 Returns a list consisting of a hashref suited for use as the argument
1900 to qsearch, and sql query string. Each is based on the PARAMS hashref
1901 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
1902 from a form. This conveniently creates the query hashref and count_query
1903 string required by the browse and search elements. As a side effect,
1904 the PARAMS hashref is untainted and keys with unexpected values are removed.
1908 sub browse_queries {
1912 'table' => 'tax_rate',
1914 'order_by' => 'ORDER BY geocode, taxclassnum',
1919 if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1920 $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1922 delete $params->{data_vendor};
1925 if ( $params->{geocode} =~ /^(\w+)$/ ) {
1926 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1927 'geocode LIKE '. dbh->quote($1.'%');
1929 delete $params->{geocode};
1932 if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1933 qsearchs( 'tax_class', {'taxclassnum' => $1} )
1936 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1937 ' taxclassnum = '. dbh->quote($1)
1939 delete $params->{taxclassnun};
1943 if ( $params->{tax_type} =~ /^(\d+)$/ );
1944 delete $params->{tax_type}
1948 if ( $params->{tax_cat} =~ /^(\d+)$/ );
1949 delete $params->{tax_cat}
1952 my @taxclassnum = ();
1953 if ($tax_type || $tax_cat ) {
1954 my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1955 $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1956 @taxclassnum = map { $_->taxclassnum }
1957 qsearch({ 'table' => 'tax_class',
1959 'extra_sql' => "WHERE taxclass $compare",
1963 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1964 join(' OR ', map { " taxclassnum = $_ " } @taxclassnum ). ' )'
1965 if ( @taxclassnum );
1967 unless ($params->{'showdisabled'}) {
1968 $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1969 "( disabled = '' OR disabled IS NULL )";
1972 $query->{extra_sql} = $extra_sql;
1974 return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1977 =item queue_liability_report PARAMS
1979 Launches a tax liability report.
1981 PARAMS needs to be a base64-encoded Storable hash containing:
1982 - beginning: the start date, as a I<user-readable string> (not a timestamp).
1983 - end: the end date of the report, likewise.
1984 - agentnum: the agent to limit the report to, if any.
1988 sub queue_liability_report {
1990 my $param = thaw(decode_base64(shift));
1993 $cgi->param('beginning', $param->{beginning});
1994 $cgi->param('ending', $param->{ending});
1995 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
1996 my $agentnum = $param->{agentnum};
1997 if ($agentnum =~ /^(\d+)$/) { $agentnum = $1; } else { $agentnum = ''; };
1998 generate_liability_report(
1999 'beginning' => $beginning,
2000 'ending' => $ending,
2001 'agentnum' => $agentnum,
2002 'p' => $param->{RootURL},
2007 =item generate_liability_report PARAMS
2009 Generates a tax liability report. PARAMS must include:
2011 - beginning, as a timestamp
2012 - ending, as a timestamp
2013 - p: the Freeside root URL, for generating links
2014 - agentnum (optional)
2018 #shit, all sorts of false laxiness w/report_newtax.cgi
2019 sub generate_liability_report {
2022 my ( $count, $last, $min_sec ) = _progressbar_foo();
2024 #let us open the temp file early
2025 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
2026 my $report = new File::Temp( TEMPLATE => 'report.tax.liability.XXXXXXXX',
2028 UNLINK => 0, # not so temp
2029 ) or die "can't open report file: $!\n";
2031 my $conf = new FS::Conf;
2032 my $money_char = $conf->config('money_char') || '$';
2035 JOIN cust_bill USING ( invnum )
2036 LEFT JOIN cust_main USING ( custnum )
2040 "LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum )";
2041 my $join_tax_loc = "LEFT JOIN tax_rate_location USING ( taxratelocationnum )";
2043 my $addl_from = " $join_cust $join_loc $join_tax_loc ";
2045 my $where = "WHERE _date >= $args{beginning} AND _date <= $args{ending} ";
2048 if ( $args{agentnum} =~ /^(\d+)$/ ) {
2049 my $agent = qsearchs('agent', { 'agentnum' => $1 } );
2050 die "agent not found" unless $agent;
2051 $agentname = $agent->agent;
2052 $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
2055 #my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
2056 my @taxparams = qw( city county state locationtaxid );
2057 my @params = ('itemdesc', @taxparams);
2059 my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
2061 #false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
2062 #to FS::Report or FS::Record or who the fuck knows where)
2063 my $scalar_sql = sub {
2064 my( $r, $param, $sql ) = @_;
2065 my $sth = dbh->prepare($sql) or die dbh->errstr;
2066 $sth->execute( map $r->$_(), @$param )
2067 or die "Unexpected error executing statement $sql: ". $sth->errstr;
2068 $sth->fetchrow_arrayref->[0] || 0;
2077 # get all distinct tuples of (tax name, state, county, city, locationtaxid)
2078 # for taxes that have been charged
2079 # (state, county, city are from tax_rate_location, not from customer data)
2080 my @tax_and_location = qsearch({ table => 'cust_bill_pkg',
2082 hashref => { pkgpart => 0 },
2083 addl_from => $addl_from,
2084 extra_sql => $where,
2087 $count = scalar(@tax_and_location);
2088 foreach my $t ( @tax_and_location ) {
2091 if ( time - $min_sec > $last ) {
2092 $args{job}->update_statustext( int( 100 * $calculated / $count ).
2099 #my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
2100 my $label = join('~', map { $t->$_ } @params);
2101 $label = 'Tax'. $label if $label =~ /^~/;
2102 unless ( exists( $taxes{$label} ) ) {
2103 my ($baselabel, @trash) = split /~/, $label;
2105 $taxes{$label}->{'label'} = join(', ', split(/~/, $label) );
2106 $taxes{$label}->{'url_param'} =
2107 join(';', map { "$_=". uri_escape($t->$_) } @params);
2110 # " payby != 'COMP' ". # breaks the entire report under 4.x
2111 # # and unnecessary since COMP accounts don't
2112 # # get taxes calculated in the first place
2113 " ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
2114 "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
2119 "FROM cust_bill_pkg $addl_from $where AND $itemdesc_loc";
2121 my $sql = "SELECT SUM(amount) $taxwhere AND cust_bill_pkg.pkgnum = 0";
2123 my $x = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
2125 $taxes{$label}->{'tax'} += $x;
2128 "JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum)";
2130 "FROM cust_bill_pkg $addl_from $creditfrom $where AND $itemdesc_loc";
2132 $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
2133 " $creditwhere AND cust_bill_pkg.pkgnum = 0";
2135 my $y = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
2137 $taxes{$label}->{'credit'} += $y;
2139 unless ( exists( $taxes{$baselabel} ) ) {
2141 $basetaxes{$baselabel}->{'label'} = $baselabel;
2142 $basetaxes{$baselabel}->{'url_param'} = "itemdesc=$baselabel";
2143 $basetaxes{$baselabel}->{'base'} = 1;
2147 $basetaxes{$baselabel}->{'tax'} += $x;
2148 $basetaxes{$baselabel}->{'credit'} += $y;
2152 # calculate customer-exemption for this tax
2153 # calculate package-exemption for this tax
2154 # calculate monthly exemption (texas tax) for this tax
2155 # count up all the cust_tax_exempt_pkg records associated with
2156 # the actual line items.
2163 $args{job}->update_statustext( "0,Sorted" );
2169 foreach my $tax ( sort { $a cmp $b } keys %taxes ) {
2170 my ($base, @trash) = split '~', $tax;
2171 my $basetax = delete( $basetaxes{$base} );
2173 if ( $basetax->{tax} == $taxes{$tax}->{tax} ) {
2174 $taxes{$tax}->{base} = 1;
2176 push @taxes, $basetax;
2179 push @taxes, $taxes{$tax};
2186 'credit' => $credit,
2191 my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
2192 $dateagentlink .= ';agentnum='. $args{agentnum}
2193 if length($agentname);
2194 my $baselink = $args{p}. "search/cust_bill_pkg.cgi?vendortax=1;" .
2196 my $creditlink = $args{p}. "search/cust_credit_bill_pkg.html?$dateagentlink";
2198 print $report <<EOF;
2200 <% include("/elements/header.html", "$agentname Tax Report - ".
2202 ? time2str('%h %o %Y ', $args{beginning} )
2206 ( $args{ending} == 4294967295
2208 : time2str('%h %o %Y', $args{ending} )
2213 <% include('/elements/table-grid.html') %>
2216 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2217 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2218 <TH CLASS="grid" BGCOLOR="#cccccc">Tax invoiced</TH>
2219 <TH CLASS="grid" BGCOLOR="#cccccc"> </TH>
2220 <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2221 <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
2225 my $bgcolor1 = '#eeeeee';
2226 my $bgcolor2 = '#ffffff';
2229 $count = scalar(@taxes);
2231 foreach my $tax ( @taxes ) {
2234 if ( time - $min_sec > $last ) {
2235 $args{job}->update_statustext( int( 100 * $calculated / $count ).
2242 if ( $bgcolor eq $bgcolor1 ) {
2243 $bgcolor = $bgcolor2;
2245 $bgcolor = $bgcolor1;
2249 if ( $tax->{'label'} ne 'Total' ) {
2250 $link = ';'. $tax->{'url_param'};
2253 print $report <<EOF;
2255 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"><% '$tax->{label}' %></TD>
2256 <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2257 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2258 <A HREF="<% '$baselink$link' %>;istax=1"><% '$money_char' %><% sprintf('%.2f', $tax->{'tax'} ) %></A>
2260 <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2261 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
2262 <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2263 <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2264 <A HREF="<% '$creditlink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
2266 <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2271 print $report <<EOF;
2278 my $reportname = $report->filename;
2281 my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
2282 $reportname =~ s/^$dropstring//;
2284 my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname";
2285 die "<a href=$reporturl>view</a>\n";
2295 Mixing automatic and manual editing works poorly at present.
2297 Tax liability calculations take too long and arguably don't belong here.
2298 Tax liability report generation not entirely safe (escaped).
2302 L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>