finish package location tax reporing, RT#4499
[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   return [$name, $amount]  # we always know how to handle disabled taxes
373     if $self->disabled;
374
375   my $taxable_charged = 0;
376   my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
377                       @$taxables;
378
379   warn "calculating taxes for ". $self->taxnum. " on ".
380     join (",", map { $_->pkgnum } @cust_bill_pkg)
381     if $DEBUG;
382
383   if ($self->passflag eq 'N') {
384     return "fatal: can't (yet) handle taxes not passed to the customer";
385   }
386
387   if ($self->maxtype != 0 && $self->maxtype != 9) {
388     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
389       '" threshold';
390   }
391
392   if ($self->maxtype == 9) {
393     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
394       '" threshold';  # "texas" tax
395   }
396
397   # we treat gross revenue as gross receipts and expect the tax data
398   # to DTRT (i.e. tax on tax rules)
399   if ($self->basetype != 0 && $self->basetype != 1 &&
400       $self->basetype != 6 && $self->basetype != 7 &&
401       $self->basetype != 8 && $self->basetype != 14
402   ) {
403     return qq!fatal: can't (yet) handle tax with "!. $self->basetype_name. 
404       '" basis';
405   }
406
407   unless ($self->setuptax =~ /^Y$/i) {
408     $taxable_charged += $_->setup foreach @cust_bill_pkg;
409   }
410   unless ($self->recurtax =~ /^Y$/i) {
411     $taxable_charged += $_->recur foreach @cust_bill_pkg;
412   }
413
414   my $taxable_units = 0;
415   unless ($self->recurtax =~ /^Y$/i) {
416     if ($self->unittype == 0) {
417       my %seen = ();
418       foreach (@cust_bill_pkg) {
419         $taxable_units += $_->units
420           unless $seen{$_->pkgnum};
421         $seen{$_->pkgnum}++;
422       }
423     }elsif ($self->unittype == 1) {
424       return qq!fatal: can't (yet) handle fee with minute unit type!;
425     }elsif ($self->unittype == 2) {
426       $taxable_units = 1;
427     }else {
428       return qq!fatal: can't (yet) handle unknown unit type in tax!.
429         $self->taxnum;
430     }
431   }
432
433   #
434   # XXX insert exemption handling here
435   #
436   # the tax or fee is applied to taxbase or feebase and then
437   # the excessrate or excess fee is applied to taxmax or feemax
438   #
439
440   $amount += $taxable_charged * $self->tax;
441   $amount += $taxable_units * $self->fee;
442   
443   warn "calculated taxes as [ $name, $amount ]\n"
444     if $DEBUG;
445
446   return {
447     'name'   => $name,
448     'amount' => $amount,
449   };
450
451 }
452
453 =item tax_on_tax CUST_MAIN
454
455 Returns a list of taxes which are candidates for taxing taxes for the
456 given customer (see L<FS::cust_main>)
457
458 =cut
459
460 sub tax_on_tax {
461   my $self = shift;
462   my $cust_main = shift;
463
464   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
465     $cust_main->custnum
466     if $DEBUG;
467
468   my $geocode = $cust_main->geocode($self->data_vendor);
469
470   # CCH oddness in m2m
471   my $dbh = dbh;
472   my $extra_sql = ' AND ('.
473     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
474                  qw(10 5 2)
475         ).
476     ')';
477
478   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
479   my $select   = 'DISTINCT ON(taxclassnum) *';
480
481   # should qsearch preface columns with the table to facilitate joins?
482   my @taxclassnums = map { $_->taxclassnum }
483     qsearch( { 'table'     => 'part_pkg_taxrate',
484                'select'    => $select,
485                'hashref'   => { 'data_vendor'      => $self->data_vendor,
486                                 'taxclassnumtaxed' => $self->taxclassnum,
487                               },
488                'extra_sql' => $extra_sql,
489                'order_by'  => $order_by,
490            } );
491
492   return () unless @taxclassnums;
493
494   $extra_sql =
495     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
496
497   qsearch({ 'table'     => 'tax_rate',
498             'hashref'   => { 'geocode' => $geocode, },
499             'extra_sql' => $extra_sql,
500          })
501
502 }
503
504 =back
505
506 =head1 SUBROUTINES
507
508 =over 4
509
510 =item batch_import
511
512 =cut
513
514 sub batch_import {
515   my ($param, $job) = @_;
516
517   my $fh = $param->{filehandle};
518   my $format = $param->{'format'};
519
520   my %insert = ();
521   my %delete = ();
522
523   my @fields;
524   my $hook;
525
526   my @column_lengths = ();
527   my @column_callbacks = ();
528   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
529     $format =~ s/-fixed//;
530     my $date_format = sub { my $r='';
531                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$1/$2/$3");
532                             $r;
533                           };
534     $column_callbacks[8] = $date_format;
535     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 );
536     push @column_lengths, 1 if $format eq 'cch-update';
537   }
538   
539   my $line;
540   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
541   if ( $job || scalar(@column_callbacks) ) {
542     my $error =
543       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
544     return $error if $error;
545   }
546   $count *=2;
547
548   if ( $format eq 'cch' || $format eq 'cch-update' ) {
549     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
550                   excessrate effective_date taxauth taxtype taxcat taxname
551                   usetax useexcessrate fee unittype feemax maxtype passflag
552                   passtype basetype );
553     push @fields, 'actionflag' if $format eq 'cch-update';
554
555     $hook = sub {
556       my $hash = shift;
557
558       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
559       $hash->{'data_vendor'} ='cch';
560       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
561
562       my $taxclassid =
563         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
564
565       my %tax_class = ( 'data_vendor'  => 'cch', 
566                         'taxclass' => $taxclassid,
567                       );
568
569       my $tax_class = qsearchs( 'tax_class', \%tax_class );
570       return "Error updating tax rate: no tax class $taxclassid"
571         unless $tax_class;
572
573       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
574
575       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
576         delete($hash->{$_});
577       }
578
579       my %passflagmap = ( '0' => '',
580                           '1' => 'Y',
581                           '2' => 'N',
582                         );
583       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
584         if exists $passflagmap{$hash->{'passflag'}};
585
586       foreach (keys %$hash) {
587         $hash->{$_} = substr($hash->{$_}, 0, 80)
588           if length($hash->{$_}) > 80;
589       }
590
591       my $actionflag = delete($hash->{'actionflag'});
592
593       $hash->{'taxname'} =~ s/`/'/g; 
594       $hash->{'taxname'} =~ s|\\|/|g;
595
596       return '' if $format eq 'cch';  # but not cch-update
597
598       if ($actionflag eq 'I') {
599         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
600       }elsif ($actionflag eq 'D') {
601         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
602       }else{
603         return "Unexpected action flag: ". $hash->{'actionflag'};
604       }
605
606       delete($hash->{$_}) for keys %$hash;
607
608       '';
609
610     };
611
612   } elsif ( $format eq 'extended' ) {
613     die "unimplemented\n";
614     @fields = qw( );
615     $hook = sub {};
616   } else {
617     die "unknown format $format";
618   }
619
620   eval "use Text::CSV_XS;";
621   die $@ if $@;
622
623   my $csv = new Text::CSV_XS;
624
625   my $imported = 0;
626
627   local $SIG{HUP} = 'IGNORE';
628   local $SIG{INT} = 'IGNORE';
629   local $SIG{QUIT} = 'IGNORE';
630   local $SIG{TERM} = 'IGNORE';
631   local $SIG{TSTP} = 'IGNORE';
632   local $SIG{PIPE} = 'IGNORE';
633
634   my $oldAutoCommit = $FS::UID::AutoCommit;
635   local $FS::UID::AutoCommit = 0;
636   my $dbh = dbh;
637   
638   while ( defined($line=<$fh>) ) {
639     $csv->parse($line) or do {
640       $dbh->rollback if $oldAutoCommit;
641       return "can't parse: ". $csv->error_input();
642     };
643
644     if ( $job ) {  # progress bar
645       if ( time - $min_sec > $last ) {
646         my $error = $job->update_statustext(
647           int( 100 * $imported / $count )
648         );
649         die $error if $error;
650         $last = time;
651       }
652     }
653
654     my @columns = $csv->fields();
655
656     my %tax_rate = ( 'data_vendor' => $format );
657     foreach my $field ( @fields ) {
658       $tax_rate{$field} = shift @columns; 
659     }
660     if ( scalar( @columns ) ) {
661       $dbh->rollback if $oldAutoCommit;
662       return "Unexpected trailing columns in line (wrong format?): $line";
663     }
664
665     my $error = &{$hook}(\%tax_rate);
666     if ( $error ) {
667       $dbh->rollback if $oldAutoCommit;
668       return $error;
669     }
670
671     if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
672
673       my $tax_rate = new FS::tax_rate( \%tax_rate );
674       $error = $tax_rate->insert;
675
676       if ( $error ) {
677         $dbh->rollback if $oldAutoCommit;
678         return "can't insert tax_rate for $line: $error";
679       }
680
681     }
682
683     $imported++;
684
685   }
686
687   for (grep { !exists($delete{$_}) } keys %insert) {
688     if ( $job ) {  # progress bar
689       if ( time - $min_sec > $last ) {
690         my $error = $job->update_statustext(
691           int( 100 * $imported / $count )
692         );
693         die $error if $error;
694         $last = time;
695       }
696     }
697
698     my $tax_rate = new FS::tax_rate( $insert{$_} );
699     my $error = $tax_rate->insert;
700
701     if ( $error ) {
702       $dbh->rollback if $oldAutoCommit;
703       my $hashref = $insert{$_};
704       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
705       return "can't insert tax_rate for $line: $error";
706     }
707
708     $imported++;
709   }
710
711   for (grep { exists($delete{$_}) } keys %insert) {
712     if ( $job ) {  # progress bar
713       if ( time - $min_sec > $last ) {
714         my $error = $job->update_statustext(
715           int( 100 * $imported / $count )
716         );
717         die $error if $error;
718         $last = time;
719       }
720     }
721
722     my $old = qsearchs( 'tax_rate', $delete{$_} );
723     unless ($old) {
724       $dbh->rollback if $oldAutoCommit;
725       $old = $delete{$_};
726       return "can't find tax_rate to replace for: ".
727         #join(" ", map { "$_ => ". $old->{$_} } @fields);
728         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
729     }
730     my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
731     $new->taxnum($old->taxnum);
732     my $error = $new->replace($old);
733
734     if ( $error ) {
735       $dbh->rollback if $oldAutoCommit;
736       my $hashref = $insert{$_};
737       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
738       return "can't replace tax_rate for $line: $error";
739     }
740
741     $imported++;
742     $imported++;
743   }
744
745   for (grep { !exists($insert{$_}) } keys %delete) {
746     if ( $job ) {  # progress bar
747       if ( time - $min_sec > $last ) {
748         my $error = $job->update_statustext(
749           int( 100 * $imported / $count )
750         );
751         die $error if $error;
752         $last = time;
753       }
754     }
755
756     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
757     unless ($tax_rate) {
758       $dbh->rollback if $oldAutoCommit;
759       $tax_rate = $delete{$_};
760       return "can't find tax_rate to delete for: ".
761         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
762         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
763     }
764     my $error = $tax_rate->delete;
765
766     if ( $error ) {
767       $dbh->rollback if $oldAutoCommit;
768       my $hashref = $delete{$_};
769       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
770       return "can't delete tax_rate for $line: $error";
771     }
772
773     $imported++;
774   }
775
776   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
777
778   return "Empty file!" unless ($imported || $format eq 'cch-update');
779
780   ''; #no error
781
782 }
783
784 =item process_batch
785
786 Load a batch import as a queued JSRPC job
787
788 =cut
789
790 sub process_batch {
791   my $job = shift;
792
793   my $param = thaw(decode_base64(shift));
794   my $format = $param->{'format'};        #well... this is all cch specific
795
796   my $files = $param->{'uploaded_files'}
797     or die "No files provided.";
798
799   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
800
801   if ($format eq 'cch' || $format eq 'cch-fixed') {
802
803     my $oldAutoCommit = $FS::UID::AutoCommit;
804     local $FS::UID::AutoCommit = 0;
805     my $dbh = dbh;
806     my $error = '';
807     my $have_location = 0;
808
809     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
810                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
811                  'ZIP',      'zipfile',   \&FS::cust_tax_location::batch_import,
812                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
813                  'DETAIL',   'detail',    \&FS::tax_rate::batch_import,
814                );
815     while( scalar(@list) ) {
816       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
817       unless ($files{$file}) {
818         next if $name eq 'PLUS4';
819         $error = "No $name supplied";
820         $error = "Neither PLUS4 nor ZIP supplied"
821           if ($name eq 'ZIP' && !$have_location);
822         next;
823       }
824       $have_location = 1 if $name eq 'PLUS4';
825       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
826       my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
827       my $filename = "$dir/".  $files{$file};
828       open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
829
830       $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
831       close $fh;
832       unlink $filename or warn "Can't delete $filename: $!";
833     }
834     
835     if ($error) {
836       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
837       die $error;
838     }else{
839       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
840     }
841
842   }elsif ($format eq 'cch-update' || $format eq 'cch-fixed-update') {
843
844     my $oldAutoCommit = $FS::UID::AutoCommit;
845     local $FS::UID::AutoCommit = 0;
846     my $dbh = dbh;
847     my $error = '';
848     my @insert_list = ();
849     my @delete_list = ();
850
851     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
852                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
853                  'ZIP',      'zipfile',   \&FS::cust_tax_location::batch_import,
854                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
855                );
856     my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
857     while( scalar(@list) ) {
858       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
859       unless ($files{$file}) {
860         my $vendor = $name eq 'ZIP' ? 'cch' : 'cch-zip';
861         next     # update expected only for previously installed location data
862           if (   ($name eq 'PLUS4' || $name eq 'ZIP')
863                && !scalar( qsearch( { table => 'cust_tax_location',
864                                       hashref => { data_vendor => $vendor },
865                                       select => 'DISTINCT data_vendor',
866                                   } )
867                          )
868              );
869
870         $error = "No $name supplied";
871         next;
872       }
873       my $filename = "$dir/".  $files{$file};
874       open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
875       unlink $filename or warn "Can't delete $filename: $!";
876
877       my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
878                                 DIR      => $dir,
879                                 UNLINK   => 0,     #meh
880                               ) or die "can't open temp file: $!\n";
881
882       my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
883                                 DIR      => $dir,
884                                 UNLINK   => 0,     #meh
885                               ) or die "can't open temp file: $!\n";
886
887       while(<$fh>) {
888         my $handle = '';
889         $handle = $ifh if $_ =~ /"I"\s*$/;
890         $handle = $dfh if $_ =~ /"D"\s*$/;
891         unless ($handle) {
892           $error = "bad input line: $_" unless $handle;
893           last;
894         }
895         print $handle $_;
896       }
897       close $fh;
898       close $ifh;
899       close $dfh;
900
901       push @insert_list, $name, $ifh->filename, $import_sub;
902       unshift @delete_list, $name, $dfh->filename, $import_sub;
903
904     }
905     while( scalar(@insert_list) ) {
906       my ($name, $file, $import_sub) =
907         (shift @insert_list, shift @insert_list, shift @insert_list);
908
909       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
910       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
911       $error ||=
912         &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
913       close $fh;
914       unlink $file or warn "Can't delete $file: $!";
915     }
916     
917     $error ||= "No DETAIL supplied"
918       unless ($files{detail});
919     open my $fh, "< $dir/". $files{detail}
920       or $error ||= "Can't open DETAIL file: $!";
921     $error ||=
922       &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
923                                   $job);
924     close $fh;
925     unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
926       if $files{detail};
927
928     while( scalar(@delete_list) ) {
929       my ($name, $file, $import_sub) =
930         (shift @delete_list, shift @delete_list, shift @delete_list);
931
932       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
933       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
934       $error ||=
935         &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
936       close $fh;
937       unlink $file or warn "Can't delete $file: $!";
938     }
939     
940     if ($error) {
941       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
942       die $error;
943     }else{
944       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
945     }
946
947   }else{
948     die "Unknown format: $format";
949   }
950
951 }
952
953 =item browse_queries PARAMS
954
955 Returns a list consisting of a hashref suited for use as the argument
956 to qsearch, and sql query string.  Each is based on the PARAMS hashref
957 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
958 from a form.  This conveniently creates the query hashref and count_query
959 string required by the browse and search elements.  As a side effect, 
960 the PARAMS hashref is untainted and keys with unexpected values are removed.
961
962 =cut
963
964 sub browse_queries {
965   my $params = shift;
966
967   my $query = {
968                 'table'     => 'tax_rate',
969                 'hashref'   => {},
970                 'order_by'  => 'ORDER BY geocode, taxclassnum',
971               },
972
973   my $extra_sql = '';
974
975   if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
976     $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
977   } else {
978     delete $params->{data_vendor};
979   }
980    
981   if ( $params->{geocode} =~ /^(\w+)$/ ) {
982     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
983                     'geocode LIKE '. dbh->quote($1.'%');
984   } else {
985     delete $params->{geocode};
986   }
987
988   if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
989        qsearchs( 'tax_class', {'taxclassnum' => $1} )
990      )
991   {
992     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
993                   ' taxclassnum  = '. dbh->quote($1)
994   } else {
995     delete $params->{taxclassnun};
996   }
997
998   my $tax_type = $1
999     if ( $params->{tax_type} =~ /^(\d+)$/ );
1000   delete $params->{tax_type}
1001     unless $tax_type;
1002
1003   my $tax_cat = $1
1004     if ( $params->{tax_cat} =~ /^(\d+)$/ );
1005   delete $params->{tax_cat}
1006     unless $tax_cat;
1007
1008   my @taxclassnum = ();
1009   if ($tax_type || $tax_cat ) {
1010     my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1011     $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1012     @taxclassnum = map { $_->taxclassnum } 
1013                    qsearch({ 'table'     => 'tax_class',
1014                              'hashref'   => {},
1015                              'extra_sql' => "WHERE taxclass $compare",
1016                           });
1017   }
1018
1019   $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1020                 join(' OR ', map { " taxclassnum  = $_ " } @taxclassnum ). ' )'
1021     if ( @taxclassnum );
1022
1023   unless ($params->{'showdisabled'}) {
1024     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1025                   "( disabled = '' OR disabled IS NULL )";
1026   }
1027
1028   $query->{extra_sql} = $extra_sql;
1029
1030   return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1031 }
1032
1033 =back
1034
1035 =head1 BUGS
1036
1037   Mixing automatic and manual editing works poorly at present.
1038
1039 =head1 SEE ALSO
1040
1041 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
1042 documentation.
1043
1044 =cut
1045
1046 1;
1047