add a config flag to ignore new style taxes instead of throwing fatal errors
[freeside.git] / FS / FS / tax_rate.pm
1 package FS::tax_rate;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me
5              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
6              %tax_passtypes );
7 use Date::Parse;
8 use Storable qw( thaw );
9 use MIME::Base64;
10 use FS::Record qw( qsearch qsearchs dbh );
11 use FS::tax_class;
12 use FS::cust_bill_pkg;
13 use FS::cust_tax_location;
14 use FS::part_pkg_taxrate;
15 use FS::cust_main;
16 use FS::Misc qw( csv_from_fixed );
17
18 @ISA = qw( FS::Record );
19
20 $DEBUG = 0;
21 $me = '[FS::tax_rate]';
22
23 =head1 NAME
24
25 FS::tax_rate - Object methods for tax_rate objects
26
27 =head1 SYNOPSIS
28
29   use FS::tax_rate;
30
31   $record = new FS::tax_rate \%hash;
32   $record = new FS::tax_rate { 'column' => 'value' };
33
34   $error = $record->insert;
35
36   $error = $new_record->replace($old_record);
37
38   $error = $record->delete;
39
40   $error = $record->check;
41
42 =head1 DESCRIPTION
43
44 An FS::tax_rate object represents a tax rate, defined by locale.
45 FS::tax_rate inherits from FS::Record.  The following fields are
46 currently supported:
47
48 =over 4
49
50 =item taxnum
51
52 primary key (assigned automatically for new tax rates)
53
54 =item geocode
55
56 a geographic location code provided by a tax data vendor
57
58 =item data_vendor
59
60 the tax data vendor
61
62 =item location
63
64 a location code provided by a tax authority
65
66 =item taxclassnum
67
68 a foreign key into FS::tax_class - the type of tax
69 referenced but FS::part_pkg_taxrate
70 eitem effective_date
71
72 the time after which the tax applies
73
74 =item tax
75
76 percentage
77
78 =item excessrate
79
80 second bracket percentage 
81
82 =item taxbase
83
84 the amount to which the tax applies (first bracket)
85
86 =item taxmax
87
88 a cap on the amount of tax if a cap exists
89
90 =item usetax
91
92 percentage on out of jurisdiction purchases
93
94 =item useexcessrate
95
96 second bracket percentage on out of jurisdiction purchases
97
98 =item unittype
99
100 one of the values in %tax_unittypes
101
102 =item fee
103
104 amount of tax per unit
105
106 =item excessfee
107
108 second bracket amount of tax per unit
109
110 =item feebase
111
112 the number of units to which the fee applies (first bracket)
113
114 =item feemax
115
116 the most units to which fees apply (first and second brackets)
117
118 =item maxtype
119
120 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
121
122 =item taxname
123
124 if defined, printed on invoices instead of "Tax"
125
126 =item taxauth
127
128 a value from %tax_authorities
129
130 =item basetype
131
132 a value from %tax_basetypes indicating the tax basis
133
134 =item passtype
135
136 a value from %tax_passtypes indicating how the tax should displayed to the customer
137
138 =item passflag
139
140 'Y', 'N', or blank indicating the tax can be passed to the customer
141
142 =item setuptax
143
144 if 'Y', this tax does not apply to setup fees
145
146 =item recurtax
147
148 if 'Y', this tax does not apply to recurring fees
149
150 =item manual
151
152 if 'Y', has been manually edited
153
154 =back
155
156 =head1 METHODS
157
158 =over 4
159
160 =item new HASHREF
161
162 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
163
164 =cut
165
166 sub table { 'tax_rate'; }
167
168 =item insert
169
170 Adds this tax rate to the database.  If there is an error, returns the error,
171 otherwise returns false.
172
173 =item delete
174
175 Deletes this tax rate from the database.  If there is an error, returns the
176 error, otherwise returns false.
177
178 =item replace OLD_RECORD
179
180 Replaces the OLD_RECORD with this one in the database.  If there is an error,
181 returns the error, otherwise returns false.
182
183 =item check
184
185 Checks all fields to make sure this is a valid tax rate.  If there is an error,
186 returns the error, otherwise returns false.  Called by the insert and replace
187 methods.
188
189 =cut
190
191 sub check {
192   my $self = shift;
193
194   foreach (qw( taxbase taxmax )) {
195     $self->$_(0) unless $self->$_;
196   }
197
198   $self->ut_numbern('taxnum')
199     || $self->ut_text('geocode')
200     || $self->ut_textn('data_vendor')
201     || $self->ut_textn('location')
202     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
203     || $self->ut_snumbern('effective_date')
204     || $self->ut_float('tax')
205     || $self->ut_floatn('excessrate')
206     || $self->ut_money('taxbase')
207     || $self->ut_money('taxmax')
208     || $self->ut_floatn('usetax')
209     || $self->ut_floatn('useexcessrate')
210     || $self->ut_numbern('unittype')
211     || $self->ut_floatn('fee')
212     || $self->ut_floatn('excessfee')
213     || $self->ut_floatn('feemax')
214     || $self->ut_numbern('maxtype')
215     || $self->ut_textn('taxname')
216     || $self->ut_numbern('taxauth')
217     || $self->ut_numbern('basetype')
218     || $self->ut_numbern('passtype')
219     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
220     || $self->ut_enum('setuptax', [ '', 'Y' ] )
221     || $self->ut_enum('recurtax', [ '', 'Y' ] )
222     || $self->ut_enum('manual', [ '', 'Y' ] )
223     || $self->ut_enum('disabled', [ '', 'Y' ] )
224     || $self->SUPER::check
225     ;
226
227 }
228
229 =item taxclass_description
230
231 Returns the human understandable value associated with the related
232 FS::tax_class.
233
234 =cut
235
236 sub taxclass_description {
237   my $self = shift;
238   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
239   $tax_class ? $tax_class->description : '';
240 }
241
242 =item unittype_name
243
244 Returns the human understandable value associated with the unittype column
245
246 =cut
247
248 %tax_unittypes = ( '0' => 'access line',
249                    '1' => 'minute',
250                    '2' => 'account',
251 );
252
253 sub unittype_name {
254   my $self = shift;
255   $tax_unittypes{$self->unittype};
256 }
257
258 =item maxtype_name
259
260 Returns the human understandable value associated with the maxtype column
261
262 =cut
263
264 %tax_maxtypes = ( '0' => 'receipts per invoice',
265                   '1' => 'receipts per item',
266                   '2' => 'total utility charges per utility tax year',
267                   '3' => 'total charges per utility tax year',
268                   '4' => 'receipts per access line',
269                   '9' => 'monthly receipts per location',
270 );
271
272 sub maxtype_name {
273   my $self = shift;
274   $tax_maxtypes{$self->maxtype};
275 }
276
277 =item basetype_name
278
279 Returns the human understandable value associated with the basetype column
280
281 =cut
282
283 %tax_basetypes = ( '0'  => 'sale price',
284                    '1'  => 'gross receipts',
285                    '2'  => 'sales taxable telecom revenue',
286                    '3'  => 'minutes carried',
287                    '4'  => 'minutes billed',
288                    '5'  => 'gross operating revenue',
289                    '6'  => 'access line',
290                    '7'  => 'account',
291                    '8'  => 'gross revenue',
292                    '9'  => 'portion gross receipts attributable to interstate service',
293                    '10' => 'access line',
294                    '11' => 'gross profits',
295                    '12' => 'tariff rate',
296                    '14' => 'account',
297                    '15' => 'prior year gross receipts',
298 );
299
300 sub basetype_name {
301   my $self = shift;
302   $tax_basetypes{$self->basetype};
303 }
304
305 =item taxauth_name
306
307 Returns the human understandable value associated with the taxauth column
308
309 =cut
310
311 %tax_authorities = ( '0' => 'federal',
312                      '1' => 'state',
313                      '2' => 'county',
314                      '3' => 'city',
315                      '4' => 'local',
316                      '5' => 'county administered by state',
317                      '6' => 'city administered by state',
318                      '7' => 'city administered by county',
319                      '8' => 'local administered by state',
320                      '9' => 'local administered by county',
321 );
322
323 sub taxauth_name {
324   my $self = shift;
325   $tax_authorities{$self->taxauth};
326 }
327
328 =item passtype_name
329
330 Returns the human understandable value associated with the passtype column
331
332 =cut
333
334 %tax_passtypes = ( '0' => 'separate tax line',
335                    '1' => 'separate surcharge line',
336                    '2' => 'surcharge not separated',
337                    '3' => 'included in base rate',
338 );
339
340 sub passtype_name {
341   my $self = shift;
342   $tax_passtypes{$self->passtype};
343 }
344
345 =item taxline TAXABLES, [ OPTIONSHASH ]
346
347 Returns a listref of a name and an amount of tax calculated for the list
348 of packages/amounts referenced by TAXABLES.  If an error occurs, a message
349 is returned as a scalar.
350
351 =cut
352
353 sub taxline {
354   my $self = shift;
355
356   my $taxables;
357   my %opt = ();
358
359   if (ref($_[0]) eq 'ARRAY') {
360     $taxables = shift;
361     %opt = @_;
362   }else{
363     $taxables = [ @_ ];
364     #exemptions would be broken in this case
365   }
366
367   my $name = $self->taxname;
368   $name = 'Other surcharges'
369     if ($self->passtype == 2);
370   my $amount = 0;
371   
372   if ( $self->disabled ) { # we always know how to handle disabled taxes
373     return {
374       'name'   => $name,
375       'amount' => $amount,
376     };
377   }
378
379   my $taxable_charged = 0;
380   my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
381                       @$taxables;
382
383   warn "calculating taxes for ". $self->taxnum. " on ".
384     join (",", map { $_->pkgnum } @cust_bill_pkg)
385     if $DEBUG;
386
387   if ($self->passflag eq 'N') {
388     # return "fatal: can't (yet) handle taxes not passed to the customer";
389     # until someone needs to track these in freeside
390     return {
391       'name'   => $name,
392       'amount' => 0,
393     };
394   }
395
396   if ($self->maxtype != 0 && $self->maxtype != 9) {
397     return $self->_fatal_or_null( 'tax with "'.
398                                     $self->maxtype_name. '" threshold'
399                                 );
400   }
401
402   if ($self->maxtype == 9) {
403     return
404       $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' );
405                                                                 # "texas" tax
406   }
407
408   # we treat gross revenue as gross receipts and expect the tax data
409   # to DTRT (i.e. tax on tax rules)
410   if ($self->basetype != 0 && $self->basetype != 1 &&
411       $self->basetype != 5 && $self->basetype != 6 &&
412       $self->basetype != 7 && $self->basetype != 8 &&
413       $self->basetype != 14
414   ) {
415     return
416       $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
417   }
418
419   unless ($self->setuptax =~ /^Y$/i) {
420     $taxable_charged += $_->setup foreach @cust_bill_pkg;
421   }
422   unless ($self->recurtax =~ /^Y$/i) {
423     $taxable_charged += $_->recur foreach @cust_bill_pkg;
424   }
425
426   my $taxable_units = 0;
427   unless ($self->recurtax =~ /^Y$/i) {
428     if ($self->unittype == 0) {
429       my %seen = ();
430       foreach (@cust_bill_pkg) {
431         $taxable_units += $_->units
432           unless $seen{$_->pkgnum};
433         $seen{$_->pkgnum}++;
434       }
435     }elsif ($self->unittype == 1) {
436       return $self->_fatal_or_null( 'fee with minute unit type' );
437     }elsif ($self->unittype == 2) {
438       $taxable_units = 1;
439     }else {
440       return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
441     }
442   }
443
444   #
445   # XXX insert exemption handling here
446   #
447   # the tax or fee is applied to taxbase or feebase and then
448   # the excessrate or excess fee is applied to taxmax or feemax
449   #
450
451   $amount += $taxable_charged * $self->tax;
452   $amount += $taxable_units * $self->fee;
453   
454   warn "calculated taxes as [ $name, $amount ]\n"
455     if $DEBUG;
456
457   return {
458     'name'   => $name,
459     'amount' => $amount,
460   };
461
462 }
463
464 sub _fatal_or_null {
465   my ($self, $error) = @_;
466
467   my $conf = new FS::Conf;
468
469   $error = "fatal: can't yet handle ". $error;
470   my $name = $self->taxname;
471   $name = 'Other surcharges'
472     if ($self->passtype == 2);
473
474   if ($conf->exists('ignore_incalculable_tax')) {
475     warn $error;
476     return { name => $name, amount => 0 };
477   } else {
478     return $error;
479   }
480 }
481
482 =item tax_on_tax CUST_MAIN
483
484 Returns a list of taxes which are candidates for taxing taxes for the
485 given customer (see L<FS::cust_main>)
486
487 =cut
488
489 sub tax_on_tax {
490   my $self = shift;
491   my $cust_main = shift;
492
493   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
494     $cust_main->custnum
495     if $DEBUG;
496
497   my $geocode = $cust_main->geocode($self->data_vendor);
498
499   # CCH oddness in m2m
500   my $dbh = dbh;
501   my $extra_sql = ' AND ('.
502     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
503                  qw(10 5 2)
504         ).
505     ')';
506
507   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
508   my $select   = 'DISTINCT ON(taxclassnum) *';
509
510   # should qsearch preface columns with the table to facilitate joins?
511   my @taxclassnums = map { $_->taxclassnum }
512     qsearch( { 'table'     => 'part_pkg_taxrate',
513                'select'    => $select,
514                'hashref'   => { 'data_vendor'      => $self->data_vendor,
515                                 'taxclassnumtaxed' => $self->taxclassnum,
516                               },
517                'extra_sql' => $extra_sql,
518                'order_by'  => $order_by,
519            } );
520
521   return () unless @taxclassnums;
522
523   $extra_sql =
524     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
525
526   qsearch({ 'table'     => 'tax_rate',
527             'hashref'   => { 'geocode' => $geocode, },
528             'extra_sql' => $extra_sql,
529          })
530
531 }
532
533 =back
534
535 =head1 SUBROUTINES
536
537 =over 4
538
539 =item batch_import
540
541 =cut
542
543 sub batch_import {
544   my ($param, $job) = @_;
545
546   my $fh = $param->{filehandle};
547   my $format = $param->{'format'};
548
549   my %insert = ();
550   my %delete = ();
551
552   my @fields;
553   my $hook;
554
555   my @column_lengths = ();
556   my @column_callbacks = ();
557   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
558     $format =~ s/-fixed//;
559     my $date_format = sub { my $r='';
560                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$1/$2/$3");
561                             $r;
562                           };
563     $column_callbacks[8] = $date_format;
564     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 );
565     push @column_lengths, 1 if $format eq 'cch-update';
566   }
567   
568   my $line;
569   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
570   if ( $job || scalar(@column_callbacks) ) {
571     my $error =
572       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
573     return $error if $error;
574   }
575   $count *=2;
576
577   if ( $format eq 'cch' || $format eq 'cch-update' ) {
578     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
579                   excessrate effective_date taxauth taxtype taxcat taxname
580                   usetax useexcessrate fee unittype feemax maxtype passflag
581                   passtype basetype );
582     push @fields, 'actionflag' if $format eq 'cch-update';
583
584     $hook = sub {
585       my $hash = shift;
586
587       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
588       $hash->{'data_vendor'} ='cch';
589       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
590
591       my $taxclassid =
592         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
593
594       my %tax_class = ( 'data_vendor'  => 'cch', 
595                         'taxclass' => $taxclassid,
596                       );
597
598       my $tax_class = qsearchs( 'tax_class', \%tax_class );
599       return "Error updating tax rate: no tax class $taxclassid"
600         unless $tax_class;
601
602       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
603
604       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
605         delete($hash->{$_});
606       }
607
608       my %passflagmap = ( '0' => '',
609                           '1' => 'Y',
610                           '2' => 'N',
611                         );
612       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
613         if exists $passflagmap{$hash->{'passflag'}};
614
615       foreach (keys %$hash) {
616         $hash->{$_} = substr($hash->{$_}, 0, 80)
617           if length($hash->{$_}) > 80;
618       }
619
620       my $actionflag = delete($hash->{'actionflag'});
621
622       $hash->{'taxname'} =~ s/`/'/g; 
623       $hash->{'taxname'} =~ s|\\|/|g;
624
625       return '' if $format eq 'cch';  # but not cch-update
626
627       if ($actionflag eq 'I') {
628         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
629       }elsif ($actionflag eq 'D') {
630         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
631       }else{
632         return "Unexpected action flag: ". $hash->{'actionflag'};
633       }
634
635       delete($hash->{$_}) for keys %$hash;
636
637       '';
638
639     };
640
641   } elsif ( $format eq 'extended' ) {
642     die "unimplemented\n";
643     @fields = qw( );
644     $hook = sub {};
645   } else {
646     die "unknown format $format";
647   }
648
649   eval "use Text::CSV_XS;";
650   die $@ if $@;
651
652   my $csv = new Text::CSV_XS;
653
654   my $imported = 0;
655
656   local $SIG{HUP} = 'IGNORE';
657   local $SIG{INT} = 'IGNORE';
658   local $SIG{QUIT} = 'IGNORE';
659   local $SIG{TERM} = 'IGNORE';
660   local $SIG{TSTP} = 'IGNORE';
661   local $SIG{PIPE} = 'IGNORE';
662
663   my $oldAutoCommit = $FS::UID::AutoCommit;
664   local $FS::UID::AutoCommit = 0;
665   my $dbh = dbh;
666   
667   while ( defined($line=<$fh>) ) {
668     $csv->parse($line) or do {
669       $dbh->rollback if $oldAutoCommit;
670       return "can't parse: ". $csv->error_input();
671     };
672
673     if ( $job ) {  # progress bar
674       if ( time - $min_sec > $last ) {
675         my $error = $job->update_statustext(
676           int( 100 * $imported / $count )
677         );
678         die $error if $error;
679         $last = time;
680       }
681     }
682
683     my @columns = $csv->fields();
684
685     my %tax_rate = ( 'data_vendor' => $format );
686     foreach my $field ( @fields ) {
687       $tax_rate{$field} = shift @columns; 
688     }
689     if ( scalar( @columns ) ) {
690       $dbh->rollback if $oldAutoCommit;
691       return "Unexpected trailing columns in line (wrong format?): $line";
692     }
693
694     my $error = &{$hook}(\%tax_rate);
695     if ( $error ) {
696       $dbh->rollback if $oldAutoCommit;
697       return $error;
698     }
699
700     if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
701
702       my $tax_rate = new FS::tax_rate( \%tax_rate );
703       $error = $tax_rate->insert;
704
705       if ( $error ) {
706         $dbh->rollback if $oldAutoCommit;
707         return "can't insert tax_rate for $line: $error";
708       }
709
710     }
711
712     $imported++;
713
714   }
715
716   for (grep { !exists($delete{$_}) } keys %insert) {
717     if ( $job ) {  # progress bar
718       if ( time - $min_sec > $last ) {
719         my $error = $job->update_statustext(
720           int( 100 * $imported / $count )
721         );
722         die $error if $error;
723         $last = time;
724       }
725     }
726
727     my $tax_rate = new FS::tax_rate( $insert{$_} );
728     my $error = $tax_rate->insert;
729
730     if ( $error ) {
731       $dbh->rollback if $oldAutoCommit;
732       my $hashref = $insert{$_};
733       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
734       return "can't insert tax_rate for $line: $error";
735     }
736
737     $imported++;
738   }
739
740   for (grep { exists($delete{$_}) } keys %insert) {
741     if ( $job ) {  # progress bar
742       if ( time - $min_sec > $last ) {
743         my $error = $job->update_statustext(
744           int( 100 * $imported / $count )
745         );
746         die $error if $error;
747         $last = time;
748       }
749     }
750
751     my $old = qsearchs( 'tax_rate', $delete{$_} );
752     unless ($old) {
753       $dbh->rollback if $oldAutoCommit;
754       $old = $delete{$_};
755       return "can't find tax_rate to replace for: ".
756         #join(" ", map { "$_ => ". $old->{$_} } @fields);
757         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
758     }
759     my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
760     $new->taxnum($old->taxnum);
761     my $error = $new->replace($old);
762
763     if ( $error ) {
764       $dbh->rollback if $oldAutoCommit;
765       my $hashref = $insert{$_};
766       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
767       return "can't replace tax_rate for $line: $error";
768     }
769
770     $imported++;
771     $imported++;
772   }
773
774   for (grep { !exists($insert{$_}) } keys %delete) {
775     if ( $job ) {  # progress bar
776       if ( time - $min_sec > $last ) {
777         my $error = $job->update_statustext(
778           int( 100 * $imported / $count )
779         );
780         die $error if $error;
781         $last = time;
782       }
783     }
784
785     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
786     unless ($tax_rate) {
787       $dbh->rollback if $oldAutoCommit;
788       $tax_rate = $delete{$_};
789       return "can't find tax_rate to delete for: ".
790         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
791         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
792     }
793     my $error = $tax_rate->delete;
794
795     if ( $error ) {
796       $dbh->rollback if $oldAutoCommit;
797       my $hashref = $delete{$_};
798       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
799       return "can't delete tax_rate for $line: $error";
800     }
801
802     $imported++;
803   }
804
805   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
806
807   return "Empty file!" unless ($imported || $format eq 'cch-update');
808
809   ''; #no error
810
811 }
812
813 =item process_batch_import
814
815 Load a batch import as a queued JSRPC job
816
817 =cut
818
819 sub process_batch_import {
820   my $job = shift;
821
822   my $param = thaw(decode_base64(shift));
823   my $format = $param->{'format'};        #well... this is all cch specific
824
825   my $files = $param->{'uploaded_files'}
826     or die "No files provided.";
827
828   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
829
830   if ($format eq 'cch' || $format eq 'cch-fixed') {
831
832     my $oldAutoCommit = $FS::UID::AutoCommit;
833     local $FS::UID::AutoCommit = 0;
834     my $dbh = dbh;
835     my $error = '';
836     my $have_location = 0;
837
838     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
839                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
840                  'ZIP',      'zipfile',   \&FS::cust_tax_location::batch_import,
841                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
842                  'DETAIL',   'detail',    \&FS::tax_rate::batch_import,
843                );
844     while( scalar(@list) ) {
845       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
846       unless ($files{$file}) {
847         next if $name eq 'PLUS4';
848         $error = "No $name supplied";
849         $error = "Neither PLUS4 nor ZIP supplied"
850           if ($name eq 'ZIP' && !$have_location);
851         next;
852       }
853       $have_location = 1 if $name eq 'PLUS4';
854       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
855       my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
856       my $filename = "$dir/".  $files{$file};
857       open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
858
859       $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
860       close $fh;
861       unlink $filename or warn "Can't delete $filename: $!";
862     }
863     
864     if ($error) {
865       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
866       die $error;
867     }else{
868       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
869     }
870
871   }elsif ($format eq 'cch-update' || $format eq 'cch-fixed-update') {
872
873     my $oldAutoCommit = $FS::UID::AutoCommit;
874     local $FS::UID::AutoCommit = 0;
875     my $dbh = dbh;
876     my $error = '';
877     my @insert_list = ();
878     my @delete_list = ();
879
880     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
881                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
882                  'ZIP',      'zipfile',   \&FS::cust_tax_location::batch_import,
883                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
884                );
885     my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
886     while( scalar(@list) ) {
887       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
888       unless ($files{$file}) {
889         my $vendor = $name eq 'ZIP' ? 'cch' : 'cch-zip';
890         next     # update expected only for previously installed location data
891           if (   ($name eq 'PLUS4' || $name eq 'ZIP')
892                && !scalar( qsearch( { table => 'cust_tax_location',
893                                       hashref => { data_vendor => $vendor },
894                                       select => 'DISTINCT data_vendor',
895                                   } )
896                          )
897              );
898
899         $error = "No $name supplied";
900         next;
901       }
902       my $filename = "$dir/".  $files{$file};
903       open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
904       unlink $filename or warn "Can't delete $filename: $!";
905
906       my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
907                                 DIR      => $dir,
908                                 UNLINK   => 0,     #meh
909                               ) or die "can't open temp file: $!\n";
910
911       my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
912                                 DIR      => $dir,
913                                 UNLINK   => 0,     #meh
914                               ) or die "can't open temp file: $!\n";
915
916       while(<$fh>) {
917         my $handle = '';
918         $handle = $ifh if $_ =~ /"I"\s*$/;
919         $handle = $dfh if $_ =~ /"D"\s*$/;
920         unless ($handle) {
921           $error = "bad input line: $_" unless $handle;
922           last;
923         }
924         print $handle $_;
925       }
926       close $fh;
927       close $ifh;
928       close $dfh;
929
930       push @insert_list, $name, $ifh->filename, $import_sub;
931       unshift @delete_list, $name, $dfh->filename, $import_sub;
932
933     }
934     while( scalar(@insert_list) ) {
935       my ($name, $file, $import_sub) =
936         (shift @insert_list, shift @insert_list, shift @insert_list);
937
938       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
939       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
940       $error ||=
941         &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
942       close $fh;
943       unlink $file or warn "Can't delete $file: $!";
944     }
945     
946     $error ||= "No DETAIL supplied"
947       unless ($files{detail});
948     open my $fh, "< $dir/". $files{detail}
949       or $error ||= "Can't open DETAIL file: $!";
950     $error ||=
951       &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
952                                   $job);
953     close $fh;
954     unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
955       if $files{detail};
956
957     while( scalar(@delete_list) ) {
958       my ($name, $file, $import_sub) =
959         (shift @delete_list, shift @delete_list, shift @delete_list);
960
961       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
962       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
963       $error ||=
964         &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
965       close $fh;
966       unlink $file or warn "Can't delete $file: $!";
967     }
968     
969     if ($error) {
970       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
971       die $error;
972     }else{
973       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
974     }
975
976   }else{
977     die "Unknown format: $format";
978   }
979
980 }
981
982 =item browse_queries PARAMS
983
984 Returns a list consisting of a hashref suited for use as the argument
985 to qsearch, and sql query string.  Each is based on the PARAMS hashref
986 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
987 from a form.  This conveniently creates the query hashref and count_query
988 string required by the browse and search elements.  As a side effect, 
989 the PARAMS hashref is untainted and keys with unexpected values are removed.
990
991 =cut
992
993 sub browse_queries {
994   my $params = shift;
995
996   my $query = {
997                 'table'     => 'tax_rate',
998                 'hashref'   => {},
999                 'order_by'  => 'ORDER BY geocode, taxclassnum',
1000               },
1001
1002   my $extra_sql = '';
1003
1004   if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1005     $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1006   } else {
1007     delete $params->{data_vendor};
1008   }
1009    
1010   if ( $params->{geocode} =~ /^(\w+)$/ ) {
1011     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1012                     'geocode LIKE '. dbh->quote($1.'%');
1013   } else {
1014     delete $params->{geocode};
1015   }
1016
1017   if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1018        qsearchs( 'tax_class', {'taxclassnum' => $1} )
1019      )
1020   {
1021     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1022                   ' taxclassnum  = '. dbh->quote($1)
1023   } else {
1024     delete $params->{taxclassnun};
1025   }
1026
1027   my $tax_type = $1
1028     if ( $params->{tax_type} =~ /^(\d+)$/ );
1029   delete $params->{tax_type}
1030     unless $tax_type;
1031
1032   my $tax_cat = $1
1033     if ( $params->{tax_cat} =~ /^(\d+)$/ );
1034   delete $params->{tax_cat}
1035     unless $tax_cat;
1036
1037   my @taxclassnum = ();
1038   if ($tax_type || $tax_cat ) {
1039     my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1040     $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1041     @taxclassnum = map { $_->taxclassnum } 
1042                    qsearch({ 'table'     => 'tax_class',
1043                              'hashref'   => {},
1044                              'extra_sql' => "WHERE taxclass $compare",
1045                           });
1046   }
1047
1048   $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1049                 join(' OR ', map { " taxclassnum  = $_ " } @taxclassnum ). ' )'
1050     if ( @taxclassnum );
1051
1052   unless ($params->{'showdisabled'}) {
1053     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1054                   "( disabled = '' OR disabled IS NULL )";
1055   }
1056
1057   $query->{extra_sql} = $extra_sql;
1058
1059   return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1060 }
1061
1062 =back
1063
1064 =head1 BUGS
1065
1066   Mixing automatic and manual editing works poorly at present.
1067
1068 =head1 SEE ALSO
1069
1070 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
1071 documentation.
1072
1073 =cut
1074
1075 1;
1076