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