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