remove useless line
[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_taxes')) {
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     my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
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     push @column_callbacks, $trim foreach (@column_lengths); # 5, 6, 15, 17 esp
567     $column_callbacks[8] = $date_format;
568   }
569   
570   my $line;
571   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
572   if ( $job || scalar(@column_callbacks) ) {
573     my $error =
574       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
575     return $error if $error;
576   }
577   $count *=2;
578
579   if ( $format eq 'cch' || $format eq 'cch-update' ) {
580     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
581                   excessrate effective_date taxauth taxtype taxcat taxname
582                   usetax useexcessrate fee unittype feemax maxtype passflag
583                   passtype basetype );
584     push @fields, 'actionflag' if $format eq 'cch-update';
585
586     $hook = sub {
587       my $hash = shift;
588
589       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
590       $hash->{'data_vendor'} ='cch';
591       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
592
593       my $taxclassid =
594         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
595
596       my %tax_class = ( 'data_vendor'  => 'cch', 
597                         'taxclass' => $taxclassid,
598                       );
599
600       my $tax_class = qsearchs( 'tax_class', \%tax_class );
601       return "Error updating tax rate: no tax class $taxclassid"
602         unless $tax_class;
603
604       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
605
606       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
607         delete($hash->{$_});
608       }
609
610       my %passflagmap = ( '0' => '',
611                           '1' => 'Y',
612                           '2' => 'N',
613                         );
614       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
615         if exists $passflagmap{$hash->{'passflag'}};
616
617       foreach (keys %$hash) {
618         $hash->{$_} = substr($hash->{$_}, 0, 80)
619           if length($hash->{$_}) > 80;
620       }
621
622       my $actionflag = delete($hash->{'actionflag'});
623
624       $hash->{'taxname'} =~ s/`/'/g; 
625       $hash->{'taxname'} =~ s|\\|/|g;
626
627       return '' if $format eq 'cch';  # but not cch-update
628
629       if ($actionflag eq 'I') {
630         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
631       }elsif ($actionflag eq 'D') {
632         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
633       }else{
634         return "Unexpected action flag: ". $hash->{'actionflag'};
635       }
636
637       delete($hash->{$_}) for keys %$hash;
638
639       '';
640
641     };
642
643   } elsif ( $format eq 'extended' ) {
644     die "unimplemented\n";
645     @fields = qw( );
646     $hook = sub {};
647   } else {
648     die "unknown format $format";
649   }
650
651   eval "use Text::CSV_XS;";
652   die $@ if $@;
653
654   my $csv = new Text::CSV_XS;
655
656   my $imported = 0;
657
658   local $SIG{HUP} = 'IGNORE';
659   local $SIG{INT} = 'IGNORE';
660   local $SIG{QUIT} = 'IGNORE';
661   local $SIG{TERM} = 'IGNORE';
662   local $SIG{TSTP} = 'IGNORE';
663   local $SIG{PIPE} = 'IGNORE';
664
665   my $oldAutoCommit = $FS::UID::AutoCommit;
666   local $FS::UID::AutoCommit = 0;
667   my $dbh = dbh;
668   
669   while ( defined($line=<$fh>) ) {
670     $csv->parse($line) or do {
671       $dbh->rollback if $oldAutoCommit;
672       return "can't parse: ". $csv->error_input();
673     };
674
675     if ( $job ) {  # progress bar
676       if ( time - $min_sec > $last ) {
677         my $error = $job->update_statustext(
678           int( 100 * $imported / $count )
679         );
680         die $error if $error;
681         $last = time;
682       }
683     }
684
685     my @columns = $csv->fields();
686
687     my %tax_rate = ( 'data_vendor' => $format );
688     foreach my $field ( @fields ) {
689       $tax_rate{$field} = shift @columns; 
690     }
691     if ( scalar( @columns ) ) {
692       $dbh->rollback if $oldAutoCommit;
693       return "Unexpected trailing columns in line (wrong format?): $line";
694     }
695
696     my $error = &{$hook}(\%tax_rate);
697     if ( $error ) {
698       $dbh->rollback if $oldAutoCommit;
699       return $error;
700     }
701
702     if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
703
704       my $tax_rate = new FS::tax_rate( \%tax_rate );
705       $error = $tax_rate->insert;
706
707       if ( $error ) {
708         $dbh->rollback if $oldAutoCommit;
709         return "can't insert tax_rate for $line: $error";
710       }
711
712     }
713
714     $imported++;
715
716   }
717
718   for (grep { !exists($delete{$_}) } keys %insert) {
719     if ( $job ) {  # progress bar
720       if ( time - $min_sec > $last ) {
721         my $error = $job->update_statustext(
722           int( 100 * $imported / $count )
723         );
724         die $error if $error;
725         $last = time;
726       }
727     }
728
729     my $tax_rate = new FS::tax_rate( $insert{$_} );
730     my $error = $tax_rate->insert;
731
732     if ( $error ) {
733       $dbh->rollback if $oldAutoCommit;
734       my $hashref = $insert{$_};
735       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
736       return "can't insert tax_rate for $line: $error";
737     }
738
739     $imported++;
740   }
741
742   for (grep { exists($delete{$_}) } keys %insert) {
743     if ( $job ) {  # progress bar
744       if ( time - $min_sec > $last ) {
745         my $error = $job->update_statustext(
746           int( 100 * $imported / $count )
747         );
748         die $error if $error;
749         $last = time;
750       }
751     }
752
753     my $old = qsearchs( 'tax_rate', $delete{$_} );
754     unless ($old) {
755       $dbh->rollback if $oldAutoCommit;
756       $old = $delete{$_};
757       return "can't find tax_rate to replace for: ".
758         #join(" ", map { "$_ => ". $old->{$_} } @fields);
759         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
760     }
761     my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
762     $new->taxnum($old->taxnum);
763     my $error = $new->replace($old);
764
765     if ( $error ) {
766       $dbh->rollback if $oldAutoCommit;
767       my $hashref = $insert{$_};
768       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
769       return "can't replace tax_rate for $line: $error";
770     }
771
772     $imported++;
773     $imported++;
774   }
775
776   for (grep { !exists($insert{$_}) } keys %delete) {
777     if ( $job ) {  # progress bar
778       if ( time - $min_sec > $last ) {
779         my $error = $job->update_statustext(
780           int( 100 * $imported / $count )
781         );
782         die $error if $error;
783         $last = time;
784       }
785     }
786
787     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
788     unless ($tax_rate) {
789       $dbh->rollback if $oldAutoCommit;
790       $tax_rate = $delete{$_};
791       return "can't find tax_rate to delete for: ".
792         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
793         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
794     }
795     my $error = $tax_rate->delete;
796
797     if ( $error ) {
798       $dbh->rollback if $oldAutoCommit;
799       my $hashref = $delete{$_};
800       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
801       return "can't delete tax_rate for $line: $error";
802     }
803
804     $imported++;
805   }
806
807   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
808
809   return "Empty file!" unless ($imported || $format eq 'cch-update');
810
811   ''; #no error
812
813 }
814
815 =item process_batch_import
816
817 Load a batch import as a queued JSRPC job
818
819 =cut
820
821 sub process_batch_import {
822   my $job = shift;
823
824   my $param = thaw(decode_base64(shift));
825   my $format = $param->{'format'};        #well... this is all cch specific
826
827   my $files = $param->{'uploaded_files'}
828     or die "No files provided.";
829
830   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
831
832   if ($format eq 'cch' || $format eq 'cch-fixed') {
833
834     my $oldAutoCommit = $FS::UID::AutoCommit;
835     local $FS::UID::AutoCommit = 0;
836     my $dbh = dbh;
837     my $error = '';
838     my $have_location = 0;
839
840     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
841                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
842                  'ZIP',      'zipfile',   \&FS::cust_tax_location::batch_import,
843                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
844                  'DETAIL',   'detail',    \&FS::tax_rate::batch_import,
845                );
846     while( scalar(@list) ) {
847       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
848       unless ($files{$file}) {
849         next if $name eq 'PLUS4';
850         $error = "No $name supplied";
851         $error = "Neither PLUS4 nor ZIP supplied"
852           if ($name eq 'ZIP' && !$have_location);
853         next;
854       }
855       $have_location = 1 if $name eq 'PLUS4';
856       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
857       my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
858       my $filename = "$dir/".  $files{$file};
859       open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
860
861       $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
862       close $fh;
863       unlink $filename or warn "Can't delete $filename: $!";
864     }
865     
866     if ($error) {
867       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
868       die $error;
869     }else{
870       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
871     }
872
873   }elsif ($format eq 'cch-update' || $format eq 'cch-fixed-update') {
874
875     my $oldAutoCommit = $FS::UID::AutoCommit;
876     local $FS::UID::AutoCommit = 0;
877     my $dbh = dbh;
878     my $error = '';
879     my @insert_list = ();
880     my @delete_list = ();
881
882     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
883                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
884                  'ZIP',      'zipfile',   \&FS::cust_tax_location::batch_import,
885                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
886                );
887     my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
888     while( scalar(@list) ) {
889       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
890       unless ($files{$file}) {
891         my $vendor = $name eq 'ZIP' ? 'cch' : 'cch-zip';
892         next     # update expected only for previously installed location data
893           if (   ($name eq 'PLUS4' || $name eq 'ZIP')
894                && !scalar( qsearch( { table => 'cust_tax_location',
895                                       hashref => { data_vendor => $vendor },
896                                       select => 'DISTINCT data_vendor',
897                                   } )
898                          )
899              );
900
901         $error = "No $name supplied";
902         next;
903       }
904       my $filename = "$dir/".  $files{$file};
905       open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
906       unlink $filename or warn "Can't delete $filename: $!";
907
908       my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
909                                 DIR      => $dir,
910                                 UNLINK   => 0,     #meh
911                               ) or die "can't open temp file: $!\n";
912
913       my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
914                                 DIR      => $dir,
915                                 UNLINK   => 0,     #meh
916                               ) or die "can't open temp file: $!\n";
917
918       my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
919       my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
920       while(<$fh>) {
921         my $handle = '';
922         $handle = $ifh if $_ =~ /$insert_pattern/;
923         $handle = $dfh if $_ =~ /$delete_pattern/;
924         unless ($handle) {
925           $error = "bad input line: $_" unless $handle;
926           last;
927         }
928         print $handle $_;
929       }
930       close $fh;
931       close $ifh;
932       close $dfh;
933
934       push @insert_list, $name, $ifh->filename, $import_sub;
935       unshift @delete_list, $name, $dfh->filename, $import_sub;
936
937     }
938     while( scalar(@insert_list) ) {
939       my ($name, $file, $import_sub) =
940         (shift @insert_list, shift @insert_list, shift @insert_list);
941
942       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
943       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
944       $error ||=
945         &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
946       close $fh;
947       unlink $file or warn "Can't delete $file: $!";
948     }
949     
950     $error ||= "No DETAIL supplied"
951       unless ($files{detail});
952     open my $fh, "< $dir/". $files{detail}
953       or $error ||= "Can't open DETAIL file: $!";
954     $error ||=
955       &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
956                                   $job);
957     close $fh;
958     unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
959       if $files{detail};
960
961     while( scalar(@delete_list) ) {
962       my ($name, $file, $import_sub) =
963         (shift @delete_list, shift @delete_list, shift @delete_list);
964
965       my $fmt = $format. ( $name eq 'ZIP' ? '-zip' : '' );
966       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
967       $error ||=
968         &{$import_sub}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
969       close $fh;
970       unlink $file or warn "Can't delete $file: $!";
971     }
972     
973     if ($error) {
974       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
975       die $error;
976     }else{
977       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
978     }
979
980   }else{
981     die "Unknown format: $format";
982   }
983
984 }
985
986 =item browse_queries PARAMS
987
988 Returns a list consisting of a hashref suited for use as the argument
989 to qsearch, and sql query string.  Each is based on the PARAMS hashref
990 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
991 from a form.  This conveniently creates the query hashref and count_query
992 string required by the browse and search elements.  As a side effect, 
993 the PARAMS hashref is untainted and keys with unexpected values are removed.
994
995 =cut
996
997 sub browse_queries {
998   my $params = shift;
999
1000   my $query = {
1001                 'table'     => 'tax_rate',
1002                 'hashref'   => {},
1003                 'order_by'  => 'ORDER BY geocode, taxclassnum',
1004               },
1005
1006   my $extra_sql = '';
1007
1008   if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1009     $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1010   } else {
1011     delete $params->{data_vendor};
1012   }
1013    
1014   if ( $params->{geocode} =~ /^(\w+)$/ ) {
1015     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1016                     'geocode LIKE '. dbh->quote($1.'%');
1017   } else {
1018     delete $params->{geocode};
1019   }
1020
1021   if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1022        qsearchs( 'tax_class', {'taxclassnum' => $1} )
1023      )
1024   {
1025     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1026                   ' taxclassnum  = '. dbh->quote($1)
1027   } else {
1028     delete $params->{taxclassnun};
1029   }
1030
1031   my $tax_type = $1
1032     if ( $params->{tax_type} =~ /^(\d+)$/ );
1033   delete $params->{tax_type}
1034     unless $tax_type;
1035
1036   my $tax_cat = $1
1037     if ( $params->{tax_cat} =~ /^(\d+)$/ );
1038   delete $params->{tax_cat}
1039     unless $tax_cat;
1040
1041   my @taxclassnum = ();
1042   if ($tax_type || $tax_cat ) {
1043     my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1044     $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1045     @taxclassnum = map { $_->taxclassnum } 
1046                    qsearch({ 'table'     => 'tax_class',
1047                              'hashref'   => {},
1048                              'extra_sql' => "WHERE taxclass $compare",
1049                           });
1050   }
1051
1052   $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1053                 join(' OR ', map { " taxclassnum  = $_ " } @taxclassnum ). ' )'
1054     if ( @taxclassnum );
1055
1056   unless ($params->{'showdisabled'}) {
1057     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1058                   "( disabled = '' OR disabled IS NULL )";
1059   }
1060
1061   $query->{extra_sql} = $extra_sql;
1062
1063   return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1064 }
1065
1066 =back
1067
1068 =head1 BUGS
1069
1070   Mixing automatic and manual editing works poorly at present.
1071
1072 =head1 SEE ALSO
1073
1074 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
1075 documentation.
1076
1077 =cut
1078
1079 1;
1080