support for cch fixed format
[freeside.git] / FS / FS / tax_rate.pm
1 package FS::tax_rate;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me
5              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
6              %tax_passtypes );
7 use Date::Parse;
8 use Storable qw( thaw );
9 use MIME::Base64;
10 use FS::Record qw( qsearch qsearchs dbh );
11 use FS::tax_class;
12 use FS::cust_bill_pkg;
13 use FS::cust_tax_location;
14 use FS::part_pkg_taxrate;
15 use FS::cust_main;
16 use FS::Misc qw( csv_from_fixed );
17
18 @ISA = qw( FS::Record );
19
20 $DEBUG = 0;
21 $me = '[FS::tax_rate]';
22
23 =head1 NAME
24
25 FS::tax_rate - Object methods for tax_rate objects
26
27 =head1 SYNOPSIS
28
29   use FS::tax_rate;
30
31   $record = new FS::tax_rate \%hash;
32   $record = new FS::tax_rate { 'column' => 'value' };
33
34   $error = $record->insert;
35
36   $error = $new_record->replace($old_record);
37
38   $error = $record->delete;
39
40   $error = $record->check;
41
42 =head1 DESCRIPTION
43
44 An FS::tax_rate object represents a tax rate, defined by locale.
45 FS::tax_rate inherits from FS::Record.  The following fields are
46 currently supported:
47
48 =over 4
49
50 =item taxnum
51
52 primary key (assigned automatically for new tax rates)
53
54 =item geocode
55
56 a geographic location code provided by a tax data vendor
57
58 =item data_vendor
59
60 the tax data vendor
61
62 =item location
63
64 a location code provided by a tax authority
65
66 =item taxclassnum
67
68 a foreign key into FS::tax_class - the type of tax
69 referenced but FS::part_pkg_taxrate
70 eitem effective_date
71
72 the time after which the tax applies
73
74 =item tax
75
76 percentage
77
78 =item excessrate
79
80 second bracket percentage 
81
82 =item taxbase
83
84 the amount to which the tax applies (first bracket)
85
86 =item taxmax
87
88 a cap on the amount of tax if a cap exists
89
90 =item usetax
91
92 percentage on out of jurisdiction purchases
93
94 =item useexcessrate
95
96 second bracket percentage on out of jurisdiction purchases
97
98 =item unittype
99
100 one of the values in %tax_unittypes
101
102 =item fee
103
104 amount of tax per unit
105
106 =item excessfee
107
108 second bracket amount of tax per unit
109
110 =item feebase
111
112 the number of units to which the fee applies (first bracket)
113
114 =item feemax
115
116 the most units to which fees apply (first and second brackets)
117
118 =item maxtype
119
120 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
121
122 =item taxname
123
124 if defined, printed on invoices instead of "Tax"
125
126 =item taxauth
127
128 a value from %tax_authorities
129
130 =item basetype
131
132 a value from %tax_basetypes indicating the tax basis
133
134 =item passtype
135
136 a value from %tax_passtypes indicating how the tax should displayed to the customer
137
138 =item passflag
139
140 'Y', 'N', or blank indicating the tax can be passed to the customer
141
142 =item setuptax
143
144 if 'Y', this tax does not apply to setup fees
145
146 =item recurtax
147
148 if 'Y', this tax does not apply to recurring fees
149
150 =item manual
151
152 if 'Y', has been manually edited
153
154 =back
155
156 =head1 METHODS
157
158 =over 4
159
160 =item new HASHREF
161
162 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
163
164 =cut
165
166 sub table { 'tax_rate'; }
167
168 =item insert
169
170 Adds this tax rate to the database.  If there is an error, returns the error,
171 otherwise returns false.
172
173 =item delete
174
175 Deletes this tax rate from the database.  If there is an error, returns the
176 error, otherwise returns false.
177
178 =item replace OLD_RECORD
179
180 Replaces the OLD_RECORD with this one in the database.  If there is an error,
181 returns the error, otherwise returns false.
182
183 =item check
184
185 Checks all fields to make sure this is a valid tax rate.  If there is an error,
186 returns the error, otherwise returns false.  Called by the insert and replace
187 methods.
188
189 =cut
190
191 sub check {
192   my $self = shift;
193
194   foreach (qw( taxbase taxmax )) {
195     $self->$_(0) unless $self->$_;
196   }
197
198   $self->ut_numbern('taxnum')
199     || $self->ut_text('geocode')
200     || $self->ut_textn('data_vendor')
201     || $self->ut_textn('location')
202     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
203     || $self->ut_snumbern('effective_date')
204     || $self->ut_float('tax')
205     || $self->ut_floatn('excessrate')
206     || $self->ut_money('taxbase')
207     || $self->ut_money('taxmax')
208     || $self->ut_floatn('usetax')
209     || $self->ut_floatn('useexcessrate')
210     || $self->ut_numbern('unittype')
211     || $self->ut_floatn('fee')
212     || $self->ut_floatn('excessfee')
213     || $self->ut_floatn('feemax')
214     || $self->ut_numbern('maxtype')
215     || $self->ut_textn('taxname')
216     || $self->ut_numbern('taxauth')
217     || $self->ut_numbern('basetype')
218     || $self->ut_numbern('passtype')
219     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
220     || $self->ut_enum('setuptax', [ '', 'Y' ] )
221     || $self->ut_enum('recurtax', [ '', 'Y' ] )
222     || $self->ut_enum('manual', [ '', 'Y' ] )
223     || $self->ut_enum('disabled', [ '', 'Y' ] )
224     || $self->SUPER::check
225     ;
226
227 }
228
229 =item taxclass_description
230
231 Returns the human understandable value associated with the related
232 FS::tax_class.
233
234 =cut
235
236 sub taxclass_description {
237   my $self = shift;
238   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
239   $tax_class ? $tax_class->description : '';
240 }
241
242 =item unittype_name
243
244 Returns the human understandable value associated with the unittype column
245
246 =cut
247
248 %tax_unittypes = ( '0' => 'access line',
249                    '1' => 'minute',
250                    '2' => 'account',
251 );
252
253 sub unittype_name {
254   my $self = shift;
255   $tax_unittypes{$self->unittype};
256 }
257
258 =item maxtype_name
259
260 Returns the human understandable value associated with the maxtype column
261
262 =cut
263
264 %tax_maxtypes = ( '0' => 'receipts per invoice',
265                   '1' => 'receipts per item',
266                   '2' => 'total utility charges per utility tax year',
267                   '3' => 'total charges per utility tax year',
268                   '4' => 'receipts per access line',
269                   '9' => 'monthly receipts per location',
270 );
271
272 sub maxtype_name {
273   my $self = shift;
274   $tax_maxtypes{$self->maxtype};
275 }
276
277 =item basetype_name
278
279 Returns the human understandable value associated with the basetype column
280
281 =cut
282
283 %tax_basetypes = ( '0'  => 'sale price',
284                    '1'  => 'gross receipts',
285                    '2'  => 'sales taxable telecom revenue',
286                    '3'  => 'minutes carried',
287                    '4'  => 'minutes billed',
288                    '5'  => 'gross operating revenue',
289                    '6'  => 'access line',
290                    '7'  => 'account',
291                    '8'  => 'gross revenue',
292                    '9'  => 'portion gross receipts attributable to interstate service',
293                    '10' => 'access line',
294                    '11' => 'gross profits',
295                    '12' => 'tariff rate',
296                    '14' => 'account',
297 );
298
299 sub basetype_name {
300   my $self = shift;
301   $tax_basetypes{$self->basetype};
302 }
303
304 =item taxauth_name
305
306 Returns the human understandable value associated with the taxauth column
307
308 =cut
309
310 %tax_authorities = ( '0' => 'federal',
311                      '1' => 'state',
312                      '2' => 'county',
313                      '3' => 'city',
314                      '4' => 'local',
315                      '5' => 'county administered by state',
316                      '6' => 'city administered by state',
317                      '7' => 'city administered by county',
318                      '8' => 'local administered by state',
319                      '9' => 'local administered by county',
320 );
321
322 sub taxauth_name {
323   my $self = shift;
324   $tax_authorities{$self->taxauth};
325 }
326
327 =item passtype_name
328
329 Returns the human understandable value associated with the passtype column
330
331 =cut
332
333 %tax_passtypes = ( '0' => 'separate tax line',
334                    '1' => 'separate surcharge line',
335                    '2' => 'surcharge not separated',
336                    '3' => 'included in base rate',
337 );
338
339 sub passtype_name {
340   my $self = shift;
341   $tax_passtypes{$self->passtype};
342 }
343
344 =item taxline CUST_BILL_PKG|AMOUNT, ...
345
346 Returns a listref of a name and an amount of tax calculated for the list
347 of packages/amounts.  If an error occurs, a message is returned as a scalar.
348
349 =cut
350
351 sub taxline {
352   my $self = shift;
353
354   my $name = $self->taxname;
355   $name = 'Other surcharges'
356     if ($self->passtype == 2);
357   my $amount = 0;
358   
359   return [$name, $amount]  # we always know how to handle disabled taxes
360     if $self->disabled;
361
362   my $taxable_charged = 0;
363   my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; } @_;
364
365   warn "calculating taxes for ". $self->taxnum. " on ".
366     join (",", map { $_->pkgnum } @cust_bill_pkg)
367     if $DEBUG;
368
369   if ($self->passflag eq 'N') {
370     return "fatal: can't (yet) handle taxes not passed to the customer";
371   }
372
373   if ($self->maxtype != 0 && $self->maxtype != 9) {
374     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
375       '" threshold';
376   }
377
378   if ($self->maxtype == 9) {
379     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
380       '" threshold';  # "texas" tax
381   }
382
383   if ($self->basetype != 0 && $self->basetype != 1 &&
384       $self->basetype != 6 && $self->basetype != 7 &&
385       $self->basetype != 14
386   ) {
387     return qq!fatal: can't (yet) handle tax with "!. $self->basetype_name. 
388       '" basis';
389   }
390
391   unless ($self->setuptax =~ /^Y$/i) {
392     $taxable_charged += $_->setup foreach @cust_bill_pkg;
393   }
394   unless ($self->recurtax =~ /^Y$/i) {
395     $taxable_charged += $_->recur foreach @cust_bill_pkg;
396   }
397
398   my $taxable_units = 0;
399   unless ($self->recurtax =~ /^Y$/i) {
400     if ($self->unittype == 0) {
401       my %seen = ();
402       foreach (@cust_bill_pkg) {
403         $taxable_units += $_->units
404           unless $seen{$_->pkgnum};
405         $seen{$_->pkgnum}++;
406       }
407     }elsif ($self->unittype == 1) {
408       return qq!fatal: can't (yet) handle fee with minute unit type!;
409     }elsif ($self->unittype == 2) {
410       $taxable_units = 1;
411     }else {
412       return qq!fatal: can't (yet) handle unknown unit type in tax!.
413         $self->taxnum;
414     }
415   }
416
417   #
418   # XXX insert exemption handling here
419   #
420   # the tax or fee is applied to taxbase or feebase and then
421   # the excessrate or excess fee is applied to taxmax or feemax
422   #
423
424   $amount += $taxable_charged * $self->tax;
425   $amount += $taxable_units * $self->fee;
426   
427   warn "calculated taxes as [ $name, $amount ]\n"
428     if $DEBUG;
429
430   return [$name, $amount];
431
432 }
433
434 =item tax_on_tax CUST_MAIN
435
436 Returns a list of taxes which are candidates for taxing taxes for the
437 given customer (see L<FS::cust_main>)
438
439 =cut
440
441 sub tax_on_tax {
442   my $self = shift;
443   my $cust_main = shift;
444
445   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
446     $cust_main->custnum
447     if $DEBUG;
448
449   my $geocode = $cust_main->geocode($self->data_vendor);
450
451   # CCH oddness in m2m
452   my $dbh = dbh;
453   my $extra_sql = ' AND ('.
454     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
455                  qw(10 5 2)
456         ).
457     ')';
458
459   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
460   my $select   = 'DISTINCT ON(taxclassnum) *';
461
462   # should qsearch preface columns with the table to facilitate joins?
463   my @taxclassnums = map { $_->taxclassnum }
464     qsearch( { 'table'     => 'part_pkg_taxrate',
465                'select'    => $select,
466                'hashref'   => { 'data_vendor'      => $self->data_vendor,
467                                 'taxclassnumtaxed' => $self->taxclassnum,
468                               },
469                'extra_sql' => $extra_sql,
470                'order_by'  => $order_by,
471            } );
472
473   return () unless @taxclassnums;
474
475   $extra_sql =
476     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
477
478   qsearch({ 'table'     => 'tax_rate',
479             'hashref'   => { 'geocode' => $geocode, },
480             'extra_sql' => $extra_sql,
481          })
482
483 }
484
485 =back
486
487 =head1 SUBROUTINES
488
489 =over 4
490
491 =item batch_import
492
493 =cut
494
495 sub batch_import {
496   my ($param, $job) = @_;
497
498   my $fh = $param->{filehandle};
499   my $format = $param->{'format'};
500
501   my %insert = ();
502   my %delete = ();
503
504   my @fields;
505   my $hook;
506
507   my @column_lengths = ();
508   my @column_callbacks = ();
509   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
510     $format =~ s/-fixed//;
511     my $date_format = sub { my $r='';
512                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$1/$2/$3");
513                             $r;
514                           };
515     $column_callbacks[8] = $date_format;
516     push @column_lengths, qw( 10 1 1 8 8 5 8 8 8 1 2 2 30 8 8 10 2 8 2 1 2 2 );
517     push @column_lengths, 1 if $format eq 'cch-update';
518   }
519   
520   my $line;
521   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
522   if ( $job || scalar(@column_callbacks) ) {
523     my $error =
524       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
525     return $error if $error;
526   }
527   $count *=2;
528
529   if ( $format eq 'cch' || $format eq 'cch-update' ) {
530     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
531                   excessrate effective_date taxauth taxtype taxcat taxname
532                   usetax useexcessrate fee unittype feemax maxtype passflag
533                   passtype basetype );
534     push @fields, 'actionflag' if $format eq 'cch-update';
535
536     $hook = sub {
537       my $hash = shift;
538
539       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
540       $hash->{'data_vendor'} ='cch';
541       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
542
543       my $taxclassid =
544         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
545
546       my %tax_class = ( 'data_vendor'  => 'cch', 
547                         'taxclass' => $taxclassid,
548                       );
549
550       my $tax_class = qsearchs( 'tax_class', \%tax_class );
551       return "Error updating tax rate: no tax class $taxclassid"
552         unless $tax_class;
553
554       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
555
556       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
557         delete($hash->{$_});
558       }
559
560       my %passflagmap = ( '0' => '',
561                           '1' => 'Y',
562                           '2' => 'N',
563                         );
564       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
565         if exists $passflagmap{$hash->{'passflag'}};
566
567       foreach (keys %$hash) {
568         $hash->{$_} = substr($hash->{$_}, 0, 80)
569           if length($hash->{$_}) > 80;
570       }
571
572       my $actionflag = delete($hash->{'actionflag'});
573       if ($actionflag eq 'I') {
574         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
575       }elsif ($actionflag eq 'D') {
576         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
577       }else{
578         return "Unexpected action flag: ". $hash->{'actionflag'};
579       }
580
581       '';
582
583     };
584
585   } elsif ( $format eq 'extended' ) {
586     die "unimplemented\n";
587     @fields = qw( );
588     $hook = sub {};
589   } else {
590     die "unknown format $format";
591   }
592
593   eval "use Text::CSV_XS;";
594   die $@ if $@;
595
596   my $csv = new Text::CSV_XS;
597
598   my $imported = 0;
599
600   local $SIG{HUP} = 'IGNORE';
601   local $SIG{INT} = 'IGNORE';
602   local $SIG{QUIT} = 'IGNORE';
603   local $SIG{TERM} = 'IGNORE';
604   local $SIG{TSTP} = 'IGNORE';
605   local $SIG{PIPE} = 'IGNORE';
606
607   my $oldAutoCommit = $FS::UID::AutoCommit;
608   local $FS::UID::AutoCommit = 0;
609   my $dbh = dbh;
610   
611   while ( defined($line=<$fh>) ) {
612     $csv->parse($line) or do {
613       $dbh->rollback if $oldAutoCommit;
614       return "can't parse: ". $csv->error_input();
615     };
616
617     if ( $job ) {  # progress bar
618       if ( time - $min_sec > $last ) {
619         my $error = $job->update_statustext(
620           int( 100 * $imported / $count )
621         );
622         die $error if $error;
623         $last = time;
624       }
625     }
626
627     my @columns = $csv->fields();
628
629     my %tax_rate = ( 'data_vendor' => $format );
630     foreach my $field ( @fields ) {
631       $tax_rate{$field} = shift @columns; 
632     }
633     if ( scalar( @columns ) ) {
634       $dbh->rollback if $oldAutoCommit;
635       return "Unexpected trailing columns in line (wrong format?): $line";
636     }
637
638     my $error = &{$hook}(\%tax_rate);
639     if ( $error ) {
640       $dbh->rollback if $oldAutoCommit;
641       return $error;
642     }
643
644     $imported++;
645
646   }
647
648   for (grep { !exists($delete{$_}) } keys %insert) {
649     if ( $job ) {  # progress bar
650       if ( time - $min_sec > $last ) {
651         my $error = $job->update_statustext(
652           int( 100 * $imported / $count )
653         );
654         die $error if $error;
655         $last = time;
656       }
657     }
658
659     my $tax_rate = new FS::tax_rate( $insert{$_} );
660     my $error = $tax_rate->insert;
661
662     if ( $error ) {
663       $dbh->rollback if $oldAutoCommit;
664       my $hashref = $insert{$_};
665       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
666       return "can't insert tax_rate for $line: $error";
667     }
668
669     $imported++;
670   }
671
672   for (grep { exists($delete{$_}) } keys %insert) {
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 $old = qsearchs( 'tax_rate', $delete{$_} );
684     unless ($old) {
685       $dbh->rollback if $oldAutoCommit;
686       $old = $delete{$_};
687       return "can't find tax_rate to replace for: ".
688         #join(" ", map { "$_ => ". $old->{$_} } @fields);
689         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
690     }
691     my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
692     $new->taxnum($old->taxnum);
693     my $error = $new->replace($old);
694
695     if ( $error ) {
696       $dbh->rollback if $oldAutoCommit;
697       my $hashref = $insert{$_};
698       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
699       return "can't replace tax_rate for $line: $error";
700     }
701
702     $imported++;
703     $imported++;
704   }
705
706   for (grep { !exists($insert{$_}) } keys %delete) {
707     if ( $job ) {  # progress bar
708       if ( time - $min_sec > $last ) {
709         my $error = $job->update_statustext(
710           int( 100 * $imported / $count )
711         );
712         die $error if $error;
713         $last = time;
714       }
715     }
716
717     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
718     unless ($tax_rate) {
719       $dbh->rollback if $oldAutoCommit;
720       $tax_rate = $delete{$_};
721       return "can't find tax_rate to delete for: ".
722         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
723         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
724     }
725     my $error = $tax_rate->delete;
726
727     if ( $error ) {
728       $dbh->rollback if $oldAutoCommit;
729       my $hashref = $delete{$_};
730       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
731       return "can't delete tax_rate for $line: $error";
732     }
733
734     $imported++;
735   }
736
737   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
738
739   return "Empty file!" unless ($imported || $format eq 'cch-update');
740
741   ''; #no error
742
743 }
744
745 =item process_batch
746
747 Load a batch import as a queued JSRPC job
748
749 =cut
750
751 sub process_batch {
752   my $job = shift;
753
754   my $param = thaw(decode_base64(shift));
755   my $format = $param->{'format'};        #well... this is all cch specific
756
757   my $files = $param->{'uploaded_files'}
758     or die "No files provided.";
759
760   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
761
762   if ($format eq 'cch' || $format eq 'cch-fixed') {
763
764     my $oldAutoCommit = $FS::UID::AutoCommit;
765     local $FS::UID::AutoCommit = 0;
766     my $dbh = dbh;
767     my $error = '';
768
769     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
770                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
771                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
772                  'DETAIL',   'detail',    \&FS::tax_rate::batch_import,
773                );
774     while( scalar(@list) ) {
775       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
776       unless ($files{$file}) {
777         $error = "No $name supplied";
778         next;
779       }
780       my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
781       my $filename = "$dir/".  $files{$file};
782       open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
783
784       $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
785       close $fh;
786       unlink $filename or warn "Can't delete $filename: $!";
787     }
788     
789     if ($error) {
790       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
791       die $error;
792     }else{
793       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
794     }
795
796   }elsif ($format eq 'cch-update' || $format eq 'cch-fixed-update') {
797
798     my $oldAutoCommit = $FS::UID::AutoCommit;
799     local $FS::UID::AutoCommit = 0;
800     my $dbh = dbh;
801     my $error = '';
802     my @insert_list = ();
803     my @delete_list = ();
804
805     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
806                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
807                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
808                );
809     my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
810     while( scalar(@list) ) {
811       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
812       unless ($files{$file}) {
813         $error = "No $name supplied";
814         next;
815       }
816       my $filename = "$dir/".  $files{$file};
817       open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
818       unlink $filename or warn "Can't delete $filename: $!";
819
820       my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
821                                 DIR      => $dir,
822                                 UNLINK   => 0,     #meh
823                               ) or die "can't open temp file: $!\n";
824
825       my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
826                                 DIR      => $dir,
827                                 UNLINK   => 0,     #meh
828                               ) or die "can't open temp file: $!\n";
829
830       while(<$fh>) {
831         my $handle = '';
832         $handle = $ifh if $_ =~ /"I"\s*$/;
833         $handle = $dfh if $_ =~ /"D"\s*$/;
834         unless ($handle) {
835           $error = "bad input line: $_" unless $handle;
836           last;
837         }
838         print $handle $_;
839       }
840       close $fh;
841       close $ifh;
842       close $dfh;
843
844       push @insert_list, $name, $ifh->filename, $import_sub;
845       unshift @delete_list, $name, $dfh->filename, $import_sub;
846
847     }
848     while( scalar(@insert_list) ) {
849       my ($name, $file, $import_sub) =
850         (shift @insert_list, shift @insert_list, shift @insert_list);
851
852       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
853       $error ||=
854         &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
855       close $fh;
856       unlink $file or warn "Can't delete $file: $!";
857     }
858     
859     $error ||= "No DETAIL supplied"
860       unless ($files{detail});
861     open my $fh, "< $dir/". $files{detail}
862       or $error ||= "Can't open DETAIL file: $!";
863     $error ||=
864       &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
865                                   $job);
866     close $fh;
867     unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
868       if $files{detail};
869
870     while( scalar(@delete_list) ) {
871       my ($name, $file, $import_sub) =
872         (shift @delete_list, shift @delete_list, shift @delete_list);
873
874       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
875       $error ||=
876         &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
877       close $fh;
878       unlink $file or warn "Can't delete $file: $!";
879     }
880     
881     if ($error) {
882       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
883       die $error;
884     }else{
885       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
886     }
887
888   }else{
889     die "Unknown format: $format";
890   }
891
892 }
893
894 =item browse_queries PARAMS
895
896 Returns a list consisting of a hashref suited for use as the argument
897 to qsearch, and sql query string.  Each is based on the PARAMS hashref
898 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
899 from a form.  This conveniently creates the query hashref and count_query
900 string required by the browse and search elements.  As a side effect, 
901 the PARAMS hashref is untainted and keys with unexpected values are removed.
902
903 =cut
904
905 sub browse_queries {
906   my $params = shift;
907
908   my $query = {
909                 'table'     => 'tax_rate',
910                 'hashref'   => {},
911                 'order_by'  => 'ORDER BY geocode, taxclassnum',
912               },
913
914   my $extra_sql = '';
915
916   if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
917     $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
918   } else {
919     delete $params->{data_vendor};
920   }
921    
922   if ( $params->{geocode} =~ /^(\w+)$/ ) {
923     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
924                     'geocode LIKE '. dbh->quote($1.'%');
925   } else {
926     delete $params->{geocode};
927   }
928
929   if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
930        qsearchs( 'tax_class', {'taxclassnum' => $1} )
931      )
932   {
933     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
934                   ' taxclassnum  = '. dbh->quote($1)
935   } else {
936     delete $params->{taxclassnun};
937   }
938
939   my $tax_type = $1
940     if ( $params->{tax_type} =~ /^(\d+)$/ );
941   delete $params->{tax_type}
942     unless $tax_type;
943
944   my $tax_cat = $1
945     if ( $params->{tax_cat} =~ /^(\d+)$/ );
946   delete $params->{tax_cat}
947     unless $tax_cat;
948
949   my @taxclassnum = ();
950   if ($tax_type || $tax_cat ) {
951     my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
952     $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
953     @taxclassnum = map { $_->taxclassnum } 
954                    qsearch({ 'table'     => 'tax_class',
955                              'hashref'   => {},
956                              'extra_sql' => "WHERE taxclass $compare",
957                           });
958   }
959
960   $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
961                 join(' OR ', map { " taxclassnum  = $_ " } @taxclassnum ). ' )'
962     if ( @taxclassnum );
963
964   unless ($params->{'showdisabled'}) {
965     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
966                   "( disabled = '' OR disabled IS NULL )";
967   }
968
969   $query->{extra_sql} = $extra_sql;
970
971   return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
972 }
973
974 =back
975
976 =head1 BUGS
977
978   Mixing automatic and manual editing works poorly at present.
979
980 =head1 SEE ALSO
981
982 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
983 documentation.
984
985 =cut
986
987 1;
988