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