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