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