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