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