with cch taxation, consider "account" taxes per- (distinct) location instead of per...
[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 %GetInfoType $keep_cch_files );
7 use Date::Parse;
8 use DateTime;
9 use DateTime::Format::Strptime;
10 use Storable qw( thaw nfreeze );
11 use IO::File;
12 use File::Temp;
13 use LWP::UserAgent;
14 use HTTP::Request;
15 use HTTP::Response;
16 use MIME::Base64;
17 use DBIx::DBSchema;
18 use DBIx::DBSchema::Table;
19 use DBIx::DBSchema::Column;
20 use FS::Record qw( qsearch qsearchs dbh dbdef );
21 use FS::tax_class;
22 use FS::cust_bill_pkg;
23 use FS::cust_tax_location;
24 use FS::tax_rate_location;
25 use FS::part_pkg_taxrate;
26 use FS::part_pkg_taxproduct;
27 use FS::cust_main;
28 use FS::Misc qw( csv_from_fixed );
29
30 use URI::Escape;
31
32 @ISA = qw( FS::Record );
33
34 $DEBUG = 0;
35 $me = '[FS::tax_rate]';
36 $keep_cch_files = 0;
37
38 =head1 NAME
39
40 FS::tax_rate - Object methods for tax_rate objects
41
42 =head1 SYNOPSIS
43
44   use FS::tax_rate;
45
46   $record = new FS::tax_rate \%hash;
47   $record = new FS::tax_rate { 'column' => 'value' };
48
49   $error = $record->insert;
50
51   $error = $new_record->replace($old_record);
52
53   $error = $record->delete;
54
55   $error = $record->check;
56
57 =head1 DESCRIPTION
58
59 An FS::tax_rate object represents a tax rate, defined by locale.
60 FS::tax_rate inherits from FS::Record.  The following fields are
61 currently supported:
62
63 =over 4
64
65 =item taxnum
66
67 primary key (assigned automatically for new tax rates)
68
69 =item geocode
70
71 a geographic location code provided by a tax data vendor
72
73 =item data_vendor
74
75 the tax data vendor
76
77 =item location
78
79 a location code provided by a tax authority
80
81 =item taxclassnum
82
83 a foreign key into FS::tax_class - the type of tax
84 referenced but FS::part_pkg_taxrate
85 eitem effective_date
86
87 the time after which the tax applies
88
89 =item tax
90
91 percentage
92
93 =item excessrate
94
95 second bracket percentage 
96
97 =item taxbase
98
99 the amount to which the tax applies (first bracket)
100
101 =item taxmax
102
103 a cap on the amount of tax if a cap exists
104
105 =item usetax
106
107 percentage on out of jurisdiction purchases
108
109 =item useexcessrate
110
111 second bracket percentage on out of jurisdiction purchases
112
113 =item unittype
114
115 one of the values in %tax_unittypes
116
117 =item fee
118
119 amount of tax per unit
120
121 =item excessfee
122
123 second bracket amount of tax per unit
124
125 =item feebase
126
127 the number of units to which the fee applies (first bracket)
128
129 =item feemax
130
131 the most units to which fees apply (first and second brackets)
132
133 =item maxtype
134
135 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
136
137 =item taxname
138
139 if defined, printed on invoices instead of "Tax"
140
141 =item taxauth
142
143 a value from %tax_authorities
144
145 =item basetype
146
147 a value from %tax_basetypes indicating the tax basis
148
149 =item passtype
150
151 a value from %tax_passtypes indicating how the tax should displayed to the customer
152
153 =item passflag
154
155 'Y', 'N', or blank indicating the tax can be passed to the customer
156
157 =item setuptax
158
159 if 'Y', this tax does not apply to setup fees
160
161 =item recurtax
162
163 if 'Y', this tax does not apply to recurring fees
164
165 =item manual
166
167 if 'Y', has been manually edited
168
169 =back
170
171 =head1 METHODS
172
173 =over 4
174
175 =item new HASHREF
176
177 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
178
179 =cut
180
181 sub table { 'tax_rate'; }
182
183 =item insert
184
185 Adds this tax rate to the database.  If there is an error, returns the error,
186 otherwise returns false.
187
188 =item delete
189
190 Deletes this tax rate from the database.  If there is an error, returns the
191 error, otherwise returns false.
192
193 =item replace OLD_RECORD
194
195 Replaces the OLD_RECORD with this one in the database.  If there is an error,
196 returns the error, otherwise returns false.
197
198 =item check
199
200 Checks all fields to make sure this is a valid tax rate.  If there is an error,
201 returns the error, otherwise returns false.  Called by the insert and replace
202 methods.
203
204 =cut
205
206 sub check {
207   my $self = shift;
208
209   foreach (qw( taxbase taxmax )) {
210     $self->$_(0) unless $self->$_;
211   }
212
213   $self->ut_numbern('taxnum')
214     || $self->ut_text('geocode')
215     || $self->ut_textn('data_vendor')
216     || $self->ut_textn('location')
217     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
218     || $self->ut_snumbern('effective_date')
219     || $self->ut_float('tax')
220     || $self->ut_floatn('excessrate')
221     || $self->ut_money('taxbase')
222     || $self->ut_money('taxmax')
223     || $self->ut_floatn('usetax')
224     || $self->ut_floatn('useexcessrate')
225     || $self->ut_numbern('unittype')
226     || $self->ut_floatn('fee')
227     || $self->ut_floatn('excessfee')
228     || $self->ut_floatn('feemax')
229     || $self->ut_numbern('maxtype')
230     || $self->ut_textn('taxname')
231     || $self->ut_numbern('taxauth')
232     || $self->ut_numbern('basetype')
233     || $self->ut_numbern('passtype')
234     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
235     || $self->ut_enum('setuptax', [ '', 'Y' ] )
236     || $self->ut_enum('recurtax', [ '', 'Y' ] )
237     || $self->ut_enum('inoutcity', [ '', 'I', 'O' ] )
238     || $self->ut_enum('inoutlocal', [ '', 'I', 'O' ] )
239     || $self->ut_enum('manual', [ '', 'Y' ] )
240     || $self->ut_enum('disabled', [ '', 'Y' ] )
241     || $self->SUPER::check
242     ;
243
244 }
245
246 =item taxclass_description
247
248 Returns the human understandable value associated with the related
249 FS::tax_class.
250
251 =cut
252
253 sub taxclass_description {
254   my $self = shift;
255   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
256   $tax_class ? $tax_class->description : '';
257 }
258
259 =item unittype_name
260
261 Returns the human understandable value associated with the unittype column
262
263 =cut
264
265 %tax_unittypes = ( '0' => 'access line',
266                    '1' => 'minute',
267                    '2' => 'account',
268 );
269
270 sub unittype_name {
271   my $self = shift;
272   $tax_unittypes{$self->unittype};
273 }
274
275 =item maxtype_name
276
277 Returns the human understandable value associated with the maxtype column
278
279 =cut
280
281 %tax_maxtypes = ( '0' => 'receipts per invoice',
282                   '1' => 'receipts per item',
283                   '2' => 'total utility charges per utility tax year',
284                   '3' => 'total charges per utility tax year',
285                   '4' => 'receipts per access line',
286                   '9' => 'monthly receipts per location',
287 );
288
289 sub maxtype_name {
290   my $self = shift;
291   $tax_maxtypes{$self->maxtype};
292 }
293
294 =item basetype_name
295
296 Returns the human understandable value associated with the basetype column
297
298 =cut
299
300 %tax_basetypes = ( '0'  => 'sale price',
301                    '1'  => 'gross receipts',
302                    '2'  => 'sales taxable telecom revenue',
303                    '3'  => 'minutes carried',
304                    '4'  => 'minutes billed',
305                    '5'  => 'gross operating revenue',
306                    '6'  => 'access line',
307                    '7'  => 'account',
308                    '8'  => 'gross revenue',
309                    '9'  => 'portion gross receipts attributable to interstate service',
310                    '10' => 'access line',
311                    '11' => 'gross profits',
312                    '12' => 'tariff rate',
313                    '14' => 'account',
314                    '15' => 'prior year gross receipts',
315 );
316
317 sub basetype_name {
318   my $self = shift;
319   $tax_basetypes{$self->basetype};
320 }
321
322 =item taxauth_name
323
324 Returns the human understandable value associated with the taxauth column
325
326 =cut
327
328 %tax_authorities = ( '0' => 'federal',
329                      '1' => 'state',
330                      '2' => 'county',
331                      '3' => 'city',
332                      '4' => 'local',
333                      '5' => 'county administered by state',
334                      '6' => 'city administered by state',
335                      '7' => 'city administered by county',
336                      '8' => 'local administered by state',
337                      '9' => 'local administered by county',
338 );
339
340 sub taxauth_name {
341   my $self = shift;
342   $tax_authorities{$self->taxauth};
343 }
344
345 =item passtype_name
346
347 Returns the human understandable value associated with the passtype column
348
349 =cut
350
351 %tax_passtypes = ( '0' => 'separate tax line',
352                    '1' => 'separate surcharge line',
353                    '2' => 'surcharge not separated',
354                    '3' => 'included in base rate',
355 );
356
357 sub passtype_name {
358   my $self = shift;
359   $tax_passtypes{$self->passtype};
360 }
361
362 =item taxline TAXABLES, [ OPTIONSHASH ]
363
364 Returns a listref of a name and an amount of tax calculated for the list
365 of packages/amounts referenced by TAXABLES.  If an error occurs, a message
366 is returned as a scalar.
367
368 =cut
369
370 sub taxline {
371   my $self = shift;
372
373   my $taxables;
374   my %opt = ();
375
376   if (ref($_[0]) eq 'ARRAY') {
377     $taxables = shift;
378     %opt = @_;
379   }else{
380     $taxables = [ @_ ];
381     #exemptions would be broken in this case
382   }
383
384   my $name = $self->taxname;
385   $name = 'Other surcharges'
386     if ($self->passtype == 2);
387   my $amount = 0;
388   
389   if ( $self->disabled ) { # we always know how to handle disabled taxes
390     return {
391       'name'   => $name,
392       'amount' => $amount,
393     };
394   }
395
396   my $taxable_charged = 0;
397   my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
398                       @$taxables;
399
400   warn "calculating taxes for ". $self->taxnum. " on ".
401     join (",", map { $_->pkgnum } @cust_bill_pkg)
402     if $DEBUG;
403
404   if ($self->passflag eq 'N') {
405     # return "fatal: can't (yet) handle taxes not passed to the customer";
406     # until someone needs to track these in freeside
407     return {
408       'name'   => $name,
409       'amount' => 0,
410     };
411   }
412
413   my $maxtype = $self->maxtype || 0;
414   if ($maxtype != 0 && $maxtype != 9) {
415     return $self->_fatal_or_null( 'tax with "'.
416                                     $self->maxtype_name. '" threshold'
417                                 );
418   }
419
420   if ($maxtype == 9) {
421     return
422       $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' );
423                                                                 # "texas" tax
424   }
425
426   # we treat gross revenue as gross receipts and expect the tax data
427   # to DTRT (i.e. tax on tax rules)
428   if ($self->basetype != 0 && $self->basetype != 1 &&
429       $self->basetype != 5 && $self->basetype != 6 &&
430       $self->basetype != 7 && $self->basetype != 8 &&
431       $self->basetype != 14
432   ) {
433     return
434       $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
435   }
436
437   unless ($self->setuptax =~ /^Y$/i) {
438     $taxable_charged += $_->setup foreach @cust_bill_pkg;
439   }
440   unless ($self->recurtax =~ /^Y$/i) {
441     $taxable_charged += $_->recur foreach @cust_bill_pkg;
442   }
443
444   my $taxable_units = 0;
445   unless ($self->recurtax =~ /^Y$/i) {
446
447     if (( $self->unittype || 0 ) == 0) { #access line
448       my %seen = ();
449       foreach (@cust_bill_pkg) {
450         $taxable_units += $_->units
451           unless $seen{$_->pkgnum}++;
452       }
453
454     } elsif ($self->unittype == 1) { #minute
455       return $self->_fatal_or_null( 'fee with minute unit type' );
456
457     } elsif ($self->unittype == 2) { #account
458       #$taxable_units = 1;
459       #number of distinct locations
460       my %seen = ();
461       foreach (@cust_bill_pkg) {
462         $taxable_units++
463           unless $seen{$_->cust_pkg->locationnum}++;
464       }
465
466     } else {
467       return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
468     }
469
470   }
471
472   #
473   # XXX insert exemption handling here
474   #
475   # the tax or fee is applied to taxbase or feebase and then
476   # the excessrate or excess fee is applied to taxmax or feemax
477   #
478
479   $amount += $taxable_charged * $self->tax;
480   $amount += $taxable_units * $self->fee;
481   
482   warn "calculated taxes as [ $name, $amount ]\n"
483     if $DEBUG;
484
485   return {
486     'name'   => $name,
487     'amount' => $amount,
488   };
489
490 }
491
492 sub _fatal_or_null {
493   my ($self, $error) = @_;
494
495   my $conf = new FS::Conf;
496
497   $error = "can't yet handle ". $error;
498   my $name = $self->taxname;
499   $name = 'Other surcharges'
500     if ($self->passtype == 2);
501
502   if ($conf->exists('ignore_incalculable_taxes')) {
503     warn "WARNING: $error; billing anyway per ignore_incalculable_taxes conf\n";
504     return { name => $name, amount => 0 };
505   } else {
506     return "fatal: $error";
507   }
508 }
509
510 =item tax_on_tax CUST_MAIN
511
512 Returns a list of taxes which are candidates for taxing taxes for the
513 given customer (see L<FS::cust_main>)
514
515 =cut
516
517     #hot
518 sub tax_on_tax {
519        #akshun
520   my $self = shift;
521   my $cust_main = shift;
522
523   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
524     $cust_main->custnum
525     if $DEBUG;
526
527   my $geocode = $cust_main->geocode($self->data_vendor);
528
529   # CCH oddness in m2m
530   my $dbh = dbh;
531   my $extra_sql = ' AND ('.
532     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
533                  qw(10 5 2)
534         ).
535     ')';
536
537   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
538   my $select   = 'DISTINCT ON(taxclassnum) *';
539
540   # should qsearch preface columns with the table to facilitate joins?
541   my @taxclassnums = map { $_->taxclassnum }
542     qsearch( { 'table'     => 'part_pkg_taxrate',
543                'select'    => $select,
544                'hashref'   => { 'data_vendor'      => $self->data_vendor,
545                                 'taxclassnumtaxed' => $self->taxclassnum,
546                               },
547                'extra_sql' => $extra_sql,
548                'order_by'  => $order_by,
549            } );
550
551   return () unless @taxclassnums;
552
553   $extra_sql =
554     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
555
556   qsearch({ 'table'     => 'tax_rate',
557             'hashref'   => { 'geocode' => $geocode, },
558             'extra_sql' => $extra_sql,
559          })
560
561 }
562
563 =item tax_rate_location
564
565 Returns an object representing the location associated with this tax
566 (see L<FS::tax_rate_location>)
567
568 =cut
569
570 sub tax_rate_location {
571   my $self = shift;
572
573   qsearchs({ 'table'     => 'tax_rate_location',
574              'hashref'   => { 'data_vendor' => $self->data_vendor, 
575                               'geocode'     => $self->geocode,
576                               'disabled'    => '',
577                             },
578           }) ||
579   new FS::tax_rate_location;
580
581 }
582
583 =back
584
585 =head1 SUBROUTINES
586
587 =over 4
588
589 =item batch_import
590
591 =cut
592
593 sub _progressbar_foo {
594   return (0, time, 5);
595 }
596
597 sub batch_import {
598   my ($param, $job) = @_;
599
600   my $fh = $param->{filehandle};
601   my $format = $param->{'format'};
602
603   my %insert = ();
604   my %delete = ();
605
606   my @fields;
607   my $hook;
608
609   my @column_lengths = ();
610   my @column_callbacks = ();
611   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
612     $format =~ s/-fixed//;
613     my $date_format = sub { my $r='';
614                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$3/$2/$1");
615                             $r;
616                           };
617     my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
618     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 );
619     push @column_lengths, 1 if $format eq 'cch-update';
620     push @column_callbacks, $trim foreach (@column_lengths); # 5, 6, 15, 17 esp
621     $column_callbacks[8] = $date_format;
622   }
623   
624   my $line;
625   my ( $count, $last, $min_sec ) = _progressbar_foo();
626   if ( $job || scalar(@column_callbacks) ) {
627     my $error =
628       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
629     return $error if $error;
630   }
631   $count *=2;
632
633   if ( $format eq 'cch' || $format eq 'cch-update' ) {
634     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
635                   excessrate effective_date taxauth taxtype taxcat taxname
636                   usetax useexcessrate fee unittype feemax maxtype passflag
637                   passtype basetype );
638     push @fields, 'actionflag' if $format eq 'cch-update';
639
640     $hook = sub {
641       my $hash = shift;
642
643       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
644       $hash->{'data_vendor'} ='cch';
645       my $parser = new DateTime::Format::Strptime( pattern => "%m/%d/%Y",
646                                                    time_zone => 'floating',
647                                                  );
648       my $dt = $parser->parse_datetime( $hash->{'effective_date'} );
649       $hash->{'effective_date'} = $dt ? $dt->epoch : '';
650
651       $hash->{$_} =~ s/\s//g foreach qw( inoutcity inoutlocal ) ; 
652       $hash->{$_} = sprintf("%.2f", $hash->{$_}) foreach qw( taxbase taxmax );
653
654       my $taxclassid =
655         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
656
657       my %tax_class = ( 'data_vendor'  => 'cch', 
658                         'taxclass' => $taxclassid,
659                       );
660
661       my $tax_class = qsearchs( 'tax_class', \%tax_class );
662       return "Error updating tax rate: no tax class $taxclassid"
663         unless $tax_class;
664
665       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
666
667       foreach (qw( taxtype taxcat )) {
668         delete($hash->{$_});
669       }
670
671       my %passflagmap = ( '0' => '',
672                           '1' => 'Y',
673                           '2' => 'N',
674                         );
675       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
676         if exists $passflagmap{$hash->{'passflag'}};
677
678       foreach (keys %$hash) {
679         $hash->{$_} = substr($hash->{$_}, 0, 80)
680           if length($hash->{$_}) > 80;
681       }
682
683       my $actionflag = delete($hash->{'actionflag'});
684
685       $hash->{'taxname'} =~ s/`/'/g; 
686       $hash->{'taxname'} =~ s|\\|/|g;
687
688       return '' if $format eq 'cch';  # but not cch-update
689
690       if ($actionflag eq 'I') {
691         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
692       }elsif ($actionflag eq 'D') {
693         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
694       }else{
695         return "Unexpected action flag: ". $hash->{'actionflag'};
696       }
697
698       delete($hash->{$_}) for keys %$hash;
699
700       '';
701
702     };
703
704   } elsif ( $format eq 'extended' ) {
705     die "unimplemented\n";
706     @fields = qw( );
707     $hook = sub {};
708   } else {
709     die "unknown format $format";
710   }
711
712   eval "use Text::CSV_XS;";
713   die $@ if $@;
714
715   my $csv = new Text::CSV_XS;
716
717   my $imported = 0;
718
719   local $SIG{HUP} = 'IGNORE';
720   local $SIG{INT} = 'IGNORE';
721   local $SIG{QUIT} = 'IGNORE';
722   local $SIG{TERM} = 'IGNORE';
723   local $SIG{TSTP} = 'IGNORE';
724   local $SIG{PIPE} = 'IGNORE';
725
726   my $oldAutoCommit = $FS::UID::AutoCommit;
727   local $FS::UID::AutoCommit = 0;
728   my $dbh = dbh;
729   
730   while ( defined($line=<$fh>) ) {
731     $csv->parse($line) or do {
732       $dbh->rollback if $oldAutoCommit;
733       return "can't parse: ". $csv->error_input();
734     };
735
736     if ( $job ) {  # progress bar
737       if ( time - $min_sec > $last ) {
738         my $error = $job->update_statustext(
739           int( 100 * $imported / $count ). ",Importing tax rates"
740         );
741         if ($error) {
742           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
743           die $error;
744         }
745         $last = time;
746       }
747     }
748
749     my @columns = $csv->fields();
750
751     my %tax_rate = ( 'data_vendor' => $format );
752     foreach my $field ( @fields ) {
753       $tax_rate{$field} = shift @columns; 
754     }
755     if ( scalar( @columns ) ) {
756       $dbh->rollback if $oldAutoCommit;
757       return "Unexpected trailing columns in line (wrong format?): $line";
758     }
759
760     my $error = &{$hook}(\%tax_rate);
761     if ( $error ) {
762       $dbh->rollback if $oldAutoCommit;
763       return $error;
764     }
765
766     if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
767
768       my $tax_rate = new FS::tax_rate( \%tax_rate );
769       $error = $tax_rate->insert;
770
771       if ( $error ) {
772         $dbh->rollback if $oldAutoCommit;
773         return "can't insert tax_rate for $line: $error";
774       }
775
776     }
777
778     $imported++;
779
780   }
781
782   for (grep { !exists($delete{$_}) } keys %insert) {
783     if ( $job ) {  # progress bar
784       if ( time - $min_sec > $last ) {
785         my $error = $job->update_statustext(
786           int( 100 * $imported / $count ). ",Importing tax rates"
787         );
788         if ($error) {
789           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
790           die $error;
791         }
792         $last = time;
793       }
794     }
795
796     my $tax_rate = new FS::tax_rate( $insert{$_} );
797     my $error = $tax_rate->insert;
798
799     if ( $error ) {
800       $dbh->rollback if $oldAutoCommit;
801       my $hashref = $insert{$_};
802       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
803       return "can't insert tax_rate for $line: $error";
804     }
805
806     $imported++;
807   }
808
809   for (grep { exists($delete{$_}) } keys %insert) {
810     if ( $job ) {  # progress bar
811       if ( time - $min_sec > $last ) {
812         my $error = $job->update_statustext(
813           int( 100 * $imported / $count ). ",Importing tax rates"
814         );
815         if ($error) {
816           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
817           die $error;
818         }
819         $last = time;
820       }
821     }
822
823     my $old = qsearchs( 'tax_rate', $delete{$_} );
824     unless ($old) {
825       $dbh->rollback if $oldAutoCommit;
826       $old = $delete{$_};
827       return "can't find tax_rate to replace for: ".
828         #join(" ", map { "$_ => ". $old->{$_} } @fields);
829         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
830     }
831     my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
832     $new->taxnum($old->taxnum);
833     my $error = $new->replace($old);
834
835     if ( $error ) {
836       $dbh->rollback if $oldAutoCommit;
837       my $hashref = $insert{$_};
838       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
839       return "can't replace tax_rate for $line: $error";
840     }
841
842     $imported++;
843     $imported++;
844   }
845
846   for (grep { !exists($insert{$_}) } keys %delete) {
847     if ( $job ) {  # progress bar
848       if ( time - $min_sec > $last ) {
849         my $error = $job->update_statustext(
850           int( 100 * $imported / $count ). ",Importing tax rates"
851         );
852         if ($error) {
853           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
854           die $error;
855         }
856         $last = time;
857       }
858     }
859
860     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
861     unless ($tax_rate) {
862       $dbh->rollback if $oldAutoCommit;
863       $tax_rate = $delete{$_};
864       return "can't find tax_rate to delete for: ".
865         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
866         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
867     }
868     my $error = $tax_rate->delete;
869
870     if ( $error ) {
871       $dbh->rollback if $oldAutoCommit;
872       my $hashref = $delete{$_};
873       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
874       return "can't delete tax_rate for $line: $error";
875     }
876
877     $imported++;
878   }
879
880   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
881
882   return "Empty file!" unless ($imported || $format eq 'cch-update');
883
884   ''; #no error
885
886 }
887
888 =item process_batch_import
889
890 Load a batch import as a queued JSRPC job
891
892 =cut
893
894 sub process_batch_import {
895   my $job = shift;
896
897   my $oldAutoCommit = $FS::UID::AutoCommit;
898   local $FS::UID::AutoCommit = 0;
899   my $dbh = dbh;
900
901   my $param = thaw(decode_base64(shift));
902   my $args = '$job, encode_base64( nfreeze( $param ) )';
903
904   my $method = '_perform_batch_import';
905   if ( $param->{reload} ) {
906     $method = 'process_batch_reload';
907   }
908
909   eval "$method($args);";
910   if ($@) {
911     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
912     die $@;
913   }
914
915   #success!
916   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
917 }
918
919 sub _perform_batch_import {
920   my $job = shift;
921
922   my $param = thaw(decode_base64(shift));
923   my $format = $param->{'format'};        #well... this is all cch specific
924
925   my $files = $param->{'uploaded_files'}
926     or die "No files provided.";
927
928   my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
929                 split /,/, $files;
930
931   if ( $format eq 'cch' || $format eq 'cch-fixed'
932     || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
933   {
934
935     my $oldAutoCommit = $FS::UID::AutoCommit;
936     local $FS::UID::AutoCommit = 0;
937     my $dbh = dbh;
938     my $error = '';
939     my @insert_list = ();
940     my @delete_list = ();
941     my @predelete_list = ();
942     my $insertname = '';
943     my $deletename = '';
944     my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
945
946     my @list = ( 'GEOCODE',  \&FS::tax_rate_location::batch_import,
947                  'CODE',     \&FS::tax_class::batch_import,
948                  'PLUS4',    \&FS::cust_tax_location::batch_import,
949                  'ZIP',      \&FS::cust_tax_location::batch_import,
950                  'TXMATRIX', \&FS::part_pkg_taxrate::batch_import,
951                  'DETAIL',   \&FS::tax_rate::batch_import,
952                );
953     while( scalar(@list) ) {
954       my ( $name, $import_sub ) = splice( @list, 0, 2 );
955       my $file = lc($name). 'file';
956
957       unless ($files{$file}) {
958         $error = "No $name supplied";
959         next;
960       }
961       next if $name eq 'DETAIL' && $format =~ /update/;
962
963       my $filename = "$dir/".  $files{$file};
964
965       if ( $format =~ /update/ ) {
966
967         ( $error, $insertname, $deletename ) =
968           _perform_cch_insert_delete_split( $name, $filename, $dir, $format )
969           unless $error;
970         last if $error;
971
972         unlink $filename or warn "Can't delete $filename: $!"
973           unless $keep_cch_files;
974         push @insert_list, $name, $insertname, $import_sub, $format;
975         if ( $name eq 'GEOCODE' ) { #handle this whole ordering issue better
976           unshift @predelete_list, $name, $deletename, $import_sub, $format;
977         } else {
978           unshift @delete_list, $name, $deletename, $import_sub, $format;
979         }
980
981       } else {
982
983         push @insert_list, $name, $filename, $import_sub, $format;
984
985       }
986
987     }
988
989     push @insert_list,
990       'DETAIL', "$dir/".$files{detail}, \&FS::tax_rate::batch_import, $format
991       if $format =~ /update/;
992
993     $error ||= _perform_cch_tax_import( $job,
994                                         [ @predelete_list ],
995                                         [ @insert_list ],
996                                         [ @delete_list ],
997     );
998     
999     
1000     @list = ( @predelete_list, @insert_list, @delete_list );
1001     while( !$keep_cch_files && scalar(@list) ) {
1002       my ( undef, $file, undef, undef ) = splice( @list, 0, 4 );
1003       unlink $file or warn "Can't delete $file: $!";
1004     }
1005
1006     if ($error) {
1007       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1008       die $error;
1009     }else{
1010       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1011     }
1012
1013   }else{
1014     die "Unknown format: $format";
1015   }
1016
1017 }
1018
1019
1020 sub _perform_cch_tax_import {
1021   my ( $job, $predelete_list, $insert_list, $delete_list ) = @_;
1022
1023   my $error = '';
1024   foreach my $list ($predelete_list, $insert_list, $delete_list) {
1025     while( scalar(@$list) ) {
1026       my ( $name, $file, $method, $format ) = splice( @$list, 0, 4 );
1027       my $fmt = "$format-update";
1028       $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
1029       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
1030       $error ||= &{$method}({ 'filehandle' => $fh, 'format' => $fmt }, $job);
1031       close $fh;
1032     }
1033   }
1034
1035   return $error;
1036 }
1037
1038 sub _perform_cch_insert_delete_split {
1039   my ($name, $filename, $dir, $format) = @_;
1040
1041   my $error = '';
1042
1043   open my $fh, "< $filename"
1044     or $error ||= "Can't open $name file $filename: $!";
1045
1046   my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
1047                             DIR      => $dir,
1048                             UNLINK   => 0,     #meh
1049                           ) or die "can't open temp file: $!\n";
1050   my $insertname = $ifh->filename;
1051
1052   my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
1053                             DIR      => $dir,
1054                             UNLINK   => 0,     #meh
1055                           ) or die "can't open temp file: $!\n";
1056   my $deletename = $dfh->filename;
1057
1058   my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
1059   my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
1060   while(<$fh>) {
1061     my $handle = '';
1062     $handle = $ifh if $_ =~ /$insert_pattern/;
1063     $handle = $dfh if $_ =~ /$delete_pattern/;
1064     unless ($handle) {
1065       $error = "bad input line: $_" unless $handle;
1066       last;
1067     }
1068     print $handle $_;
1069   }
1070   close $fh;
1071   close $ifh;
1072   close $dfh;
1073
1074   return ($error, $insertname, $deletename);
1075 }
1076
1077 sub _perform_cch_diff {
1078   my ($name, $newdir, $olddir) = @_;
1079
1080   my %oldlines = ();
1081
1082   if ($olddir) {
1083     open my $oldcsvfh, "$olddir/$name.txt"
1084       or die "failed to open $olddir/$name.txt: $!\n";
1085
1086     while(<$oldcsvfh>) {
1087       chomp;
1088       $oldlines{$_} = 1;
1089     }
1090     close $oldcsvfh;
1091   }
1092
1093   open my $newcsvfh, "$newdir/$name.txt"
1094     or die "failed to open $newdir/$name.txt: $!\n";
1095     
1096   my $dfh = new File::Temp( TEMPLATE => "$name.diff.XXXXXXXX",
1097                             DIR      => "$newdir",
1098                             UNLINK   => 0,     #meh
1099                           ) or die "can't open temp file: $!\n";
1100   my $diffname = $dfh->filename;
1101
1102   while(<$newcsvfh>) {
1103     chomp;
1104     if (exists($oldlines{$_})) {
1105       $oldlines{$_} = 0;
1106     } else {
1107       print $dfh $_, ',"I"', "\n";
1108     }
1109   }
1110   close $newcsvfh;
1111
1112   for (keys %oldlines) {
1113     print $dfh $_, ',"D"', "\n" if $oldlines{$_};
1114   }
1115
1116   close $dfh;
1117
1118   return $diffname;
1119 }
1120
1121 sub _cch_fetch_and_unzip {
1122   my ( $job, $urls, $secret, $dir ) = @_;
1123
1124   my $ua = new LWP::UserAgent;
1125   foreach my $url (split ',', $urls) {
1126     my @name = split '/', $url;  #somewhat restrictive
1127     my $name = pop @name;
1128     $name =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1129     $name = $1;
1130       
1131     open my $taxfh, ">$dir/$name" or die "Can't open $dir/$name: $!\n";
1132      
1133     my ( $imported, $last, $min_sec ) = _progressbar_foo();
1134     my $res = $ua->request(
1135       new HTTP::Request( GET => $url ),
1136       sub {
1137             print $taxfh $_[0] or die "Can't write to $dir/$name: $!\n";
1138             my $content_length = $_[1]->content_length;
1139             $imported += length($_[0]);
1140             if ( time - $min_sec > $last ) {
1141               my $error = $job->update_statustext(
1142                 ($content_length ? int(100 * $imported/$content_length) : 0 ).
1143                 ",Downloading data from CCH"
1144               );
1145               die $error if $error;
1146               $last = time;
1147             }
1148       },
1149     );
1150     die "download of $url failed: ". $res->status_line
1151       unless $res->is_success;
1152       
1153     close $taxfh;
1154     my $error = $job->update_statustext( "0,Unpacking data" );
1155     die $error if $error;
1156     $secret =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1157     $secret = $1;
1158     system('unzip', "-P", $secret, "-d", "$dir",  "$dir/$name") == 0
1159       or die "unzip -P $secret -d $dir $dir/$name failed";
1160     #unlink "$dir/$name";
1161   }
1162 }
1163  
1164 sub _cch_extract_csv_from_dbf {
1165   my ( $job, $dir, $name ) = @_;
1166
1167   eval "use Text::CSV_XS;";
1168   die $@ if $@;
1169
1170   eval "use XBase;";
1171   die $@ if $@;
1172
1173   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1174   my $error = $job->update_statustext( "0,Unpacking $name" );
1175   die $error if $error;
1176   warn "opening $dir.new/$name.dbf\n" if $DEBUG;
1177   my $table = new XBase 'name' => "$dir.new/$name.dbf";
1178   die "failed to access $dir.new/$name.dbf: ". XBase->errstr
1179     unless defined($table);
1180   my $count = $table->last_record; # approximately;
1181   open my $csvfh, ">$dir.new/$name.txt"
1182     or die "failed to open $dir.new/$name.txt: $!\n";
1183
1184   my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1185   my @fields = $table->field_names;
1186   my $cursor = $table->prepare_select;
1187   my $format_date =
1188     sub { my $date = shift;
1189           $date =~ /^(\d{4})(\d{2})(\d{2})$/ && ($date = "$2/$3/$1");
1190           $date;
1191         };
1192   while (my $row = $cursor->fetch_hashref) {
1193     $csv->combine( map { ($table->field_type($_) eq 'D')
1194                          ? &{$format_date}($row->{$_}) 
1195                          : $row->{$_}
1196                        }
1197                    @fields
1198     );
1199     print $csvfh $csv->string, "\n";
1200     $imported++;
1201     if ( time - $min_sec > $last ) {
1202       my $error = $job->update_statustext(
1203         int(100 * $imported/$count).  ",Unpacking $name"
1204       );
1205       die $error if $error;
1206       $last = time;
1207     }
1208   }
1209   $table->close;
1210   close $csvfh;
1211 }
1212
1213 sub _remember_disabled_taxes {
1214   my ( $job, $format, $disabled_tax_rate ) = @_;
1215
1216   # cch specific hash
1217
1218   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1219
1220   my @items = qsearch( { table   => 'tax_rate',
1221                          hashref => { disabled => 'Y',
1222                                       data_vendor => $format,
1223                                     },
1224                          select  => 'geocode, taxclassnum',
1225                        }
1226                      );
1227   my $count = scalar(@items);
1228   foreach my $tax_rate ( @items ) {
1229     if ( time - $min_sec > $last ) {
1230       $job->update_statustext(
1231         int( 100 * $imported / $count ). ",Remembering disabled taxes"
1232       );
1233       $last = time;
1234     }
1235     $imported++;
1236     my $tax_class =
1237       qsearchs( 'tax_class', { taxclassnum => $tax_rate->taxclassnum } );
1238     unless ( $tax_class ) {
1239       warn "failed to find tax_class ". $tax_rate->taxclassnum;
1240       next;
1241     }
1242     $disabled_tax_rate->{$tax_rate->geocode. ':'. $tax_class->taxclass} = 1;
1243   }
1244 }
1245
1246 sub _remember_tax_products {
1247   my ( $job, $format, $taxproduct ) = @_;
1248
1249   # XXX FIXME  this loop only works when cch is the only data provider
1250
1251   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1252
1253   my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
1254                   "0 < ( SELECT count(*) from part_pkg_option WHERE ".
1255                   "       part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
1256                   "       optionname LIKE 'usage_taxproductnum_%' AND ".
1257                   "       optionvalue != '' )";
1258   my @items = qsearch( { table => 'part_pkg',
1259                          select  => 'DISTINCT pkgpart,taxproductnum',
1260                          hashref => {},
1261                          extra_sql => $extra_sql,
1262                        }
1263                      );
1264   my $count = scalar(@items);
1265   foreach my $part_pkg ( @items ) {
1266     if ( time - $min_sec > $last ) {
1267       $job->update_statustext(
1268         int( 100 * $imported / $count ). ",Remembering tax products"
1269       );
1270       $last = time;
1271     }
1272     $imported++;
1273     warn "working with package part ". $part_pkg->pkgpart.
1274       "which has a taxproductnum of ". $part_pkg->taxproductnum. "\n" if $DEBUG;
1275     my $part_pkg_taxproduct = $part_pkg->taxproduct('');
1276     $taxproduct->{$part_pkg->pkgpart}->{''} = $part_pkg_taxproduct->taxproduct
1277       if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1278
1279     foreach my $option ( $part_pkg->part_pkg_option ) {
1280       next unless $option->optionname =~ /^usage_taxproductnum_(\w+)$/;
1281       my $class = $1;
1282
1283       $part_pkg_taxproduct = $part_pkg->taxproduct($class);
1284       $taxproduct->{$part_pkg->pkgpart}->{$class} =
1285           $part_pkg_taxproduct->taxproduct
1286         if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1287     }
1288   }
1289 }
1290
1291 sub _restore_remembered_tax_products {
1292   my ( $job, $format, $taxproduct ) = @_;
1293
1294   # cch specific
1295
1296   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1297   my $count = scalar(keys %$taxproduct);
1298   foreach my $pkgpart ( keys %$taxproduct ) {
1299     warn "restoring taxproductnums on pkgpart $pkgpart\n" if $DEBUG;
1300     if ( time - $min_sec > $last ) {
1301       $job->update_statustext(
1302         int( 100 * $imported / $count ). ",Restoring tax products"
1303       );
1304       $last = time;
1305     }
1306     $imported++;
1307
1308     my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkgpart } );
1309     unless ( $part_pkg ) {
1310       return "somehow failed to find part_pkg with pkgpart $pkgpart!\n";
1311     }
1312
1313     my %options = $part_pkg->options;
1314     my %pkg_svc = map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc;
1315     my $primary_svc = $part_pkg->svcpart;
1316     my $new = new FS::part_pkg { $part_pkg->hash };
1317
1318     foreach my $class ( keys %{ $taxproduct->{$pkgpart} } ) {
1319       warn "working with class '$class'\n" if $DEBUG;
1320       my $part_pkg_taxproduct =
1321         qsearchs( 'part_pkg_taxproduct',
1322                   { taxproduct  => $taxproduct->{$pkgpart}->{$class},
1323                     data_vendor => $format,
1324                   }
1325                 );
1326
1327       unless ( $part_pkg_taxproduct ) {
1328         return "failed to find part_pkg_taxproduct (".
1329           $taxproduct->{$pkgpart}->{$class}. ") for pkgpart $pkgpart\n";
1330       }
1331
1332       if ( $class eq '' ) {
1333         $new->taxproductnum($part_pkg_taxproduct->taxproductnum);
1334         next;
1335       }
1336
1337       $options{"usage_taxproductnum_$class"} =
1338         $part_pkg_taxproduct->taxproductnum;
1339
1340     }
1341
1342     my $error = $new->replace( $part_pkg,
1343                                'pkg_svc' => \%pkg_svc,
1344                                'primary_svc' => $primary_svc,
1345                                'options' => \%options,
1346     );
1347       
1348     return $error if $error;
1349
1350   }
1351
1352   '';
1353 }
1354
1355 sub _restore_remembered_disabled_taxes {
1356   my ( $job, $format, $disabled_tax_rate ) = @_;
1357
1358   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1359   my $count = scalar(keys %$disabled_tax_rate);
1360   foreach my $key (keys %$disabled_tax_rate) {
1361     if ( time - $min_sec > $last ) {
1362       $job->update_statustext(
1363         int( 100 * $imported / $count ). ",Disabling tax rates"
1364       );
1365       $last = time;
1366     }
1367     $imported++;
1368     my ($geocode,$taxclass) = split /:/, $key, 2;
1369     my @tax_class = qsearch( 'tax_class', { data_vendor => $format,
1370                                             taxclass    => $taxclass,
1371                                           } );
1372     return "found multiple tax_class records for format $format class $taxclass"
1373       if scalar(@tax_class) > 1;
1374       
1375     unless (scalar(@tax_class)) {
1376       warn "no tax_class for format $format class $taxclass\n";
1377       next;
1378     }
1379
1380     my @tax_rate =
1381       qsearch('tax_rate', { data_vendor  => $format,
1382                             geocode      => $geocode,
1383                             taxclassnum  => $tax_class[0]->taxclassnum,
1384                           }
1385     );
1386
1387     if (scalar(@tax_rate) > 1) {
1388       return "found multiple tax_rate records for format $format geocode ".
1389              "$geocode and taxclass $taxclass ( taxclassnum ".
1390              $tax_class[0]->taxclassnum.  " )";
1391     }
1392       
1393     if (scalar(@tax_rate)) {
1394       $tax_rate[0]->disabled('Y');
1395       my $error = $tax_rate[0]->replace;
1396       return $error if $error;
1397     }
1398   }
1399 }
1400
1401 sub _remove_old_tax_data {
1402   my ( $job, $format ) = @_;
1403
1404   my $dbh = dbh;
1405   my $error = $job->update_statustext( "0,Removing old tax data" );
1406   die $error if $error;
1407
1408   my $sql = "UPDATE public.tax_rate_location SET disabled='Y' ".
1409     "WHERE data_vendor = ".  $dbh->quote($format);
1410   $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1411
1412   my @table = qw(
1413     tax_rate part_pkg_taxrate part_pkg_taxproduct tax_class cust_tax_location
1414   );
1415   foreach my $table ( @table ) {
1416     $sql = "DELETE FROM public.$table WHERE data_vendor = ".
1417       $dbh->quote($format);
1418     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1419   }
1420
1421   if ( $format eq 'cch' ) {
1422     $sql = "DELETE FROM public.cust_tax_location WHERE data_vendor = ".
1423       $dbh->quote("$format-zip");
1424     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1425   }
1426
1427   '';
1428 }
1429
1430 sub _create_temporary_tables {
1431   my ( $job, $format ) = @_;
1432
1433   my $dbh = dbh;
1434   my $error = $job->update_statustext( "0,Creating temporary tables" );
1435   die $error if $error;
1436
1437   my @table = qw( tax_rate
1438                   tax_rate_location
1439                   part_pkg_taxrate
1440                   part_pkg_taxproduct
1441                   tax_class
1442                   cust_tax_location
1443   );
1444   foreach my $table ( @table ) {
1445     my $sql =
1446       "CREATE TEMPORARY TABLE $table ( LIKE $table INCLUDING DEFAULTS )";
1447     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1448   }
1449
1450   '';
1451 }
1452
1453 sub _copy_from_temp {
1454   my ( $job, $format ) = @_;
1455
1456   my $dbh = dbh;
1457   my $error = $job->update_statustext( "0,Making permanent" );
1458   die $error if $error;
1459
1460   my @table = qw( tax_rate
1461                   tax_rate_location
1462                   part_pkg_taxrate
1463                   part_pkg_taxproduct
1464                   tax_class
1465                   cust_tax_location
1466   );
1467   foreach my $table ( @table ) {
1468     my $sql =
1469       "INSERT INTO public.$table SELECT * from $table";
1470     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1471   }
1472
1473   '';
1474 }
1475
1476 =item process_download_and_reload
1477
1478 Download and process a tax update as a queued JSRPC job after wiping the
1479 existing wipable tax data.
1480
1481 =cut
1482
1483 sub process_download_and_reload {
1484   _process_reload('process_download_and_update', @_);
1485 }
1486
1487   
1488 =item process_batch_reload
1489
1490 Load and process a tax update from the provided files as a queued JSRPC job
1491 after wiping the existing wipable tax data.
1492
1493 =cut
1494
1495 sub process_batch_reload {
1496   _process_reload('_perform_batch_import', @_);
1497 }
1498
1499   
1500 sub _process_reload {
1501   my ( $method, $job ) = ( shift, shift );
1502
1503   my $param = thaw(decode_base64($_[0]));
1504   my $format = $param->{'format'};        #well... this is all cch specific
1505
1506   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1507
1508   if ( $job ) {  # progress bar
1509     my $error = $job->update_statustext( 0 );
1510     die $error if $error;
1511   }
1512
1513   my $oldAutoCommit = $FS::UID::AutoCommit;
1514   local $FS::UID::AutoCommit = 0;
1515   my $dbh = dbh;
1516   my $error = '';
1517
1518   my $sql =
1519     "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
1520     "USING (taxclassnum) WHERE data_vendor = '$format'";
1521   my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1522   $sth->execute
1523     or die "Unexpected error executing statement $sql: ". $sth->errstr;
1524   die "Don't (yet) know how to handle part_pkg_taxoverride records."
1525     if $sth->fetchrow_arrayref->[0];
1526
1527   # really should get a table EXCLUSIVE lock here
1528
1529   #remember disabled taxes
1530   my %disabled_tax_rate = ();
1531   $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
1532
1533   #remember tax products
1534   my %taxproduct = ();
1535   $error ||= _remember_tax_products( $job, $format, \%taxproduct );
1536
1537   #create temp tables
1538   $error ||= _create_temporary_tables( $job, $format );
1539
1540   #import new data
1541   unless ($error) {
1542     my $args = '$job, @_';
1543     eval "$method($args);";
1544     $error = $@ if $@;
1545   }
1546
1547   #restore taxproducts
1548   $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
1549
1550   #disable tax_rates
1551   $error ||=
1552    _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
1553
1554   #wipe out the old data
1555   $error ||= _remove_old_tax_data( $job, $format ); 
1556
1557   #untemporize
1558   $error ||= _copy_from_temp( $job, $format );
1559
1560   if ($error) {
1561     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1562     die $error;
1563   }
1564
1565   #success!
1566   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1567 }
1568
1569
1570 =item process_download_and_update
1571
1572 Download and process a tax update as a queued JSRPC job
1573
1574 =cut
1575
1576 sub process_download_and_update {
1577   my $job = shift;
1578
1579   my $param = thaw(decode_base64(shift));
1580   my $format = $param->{'format'};        #well... this is all cch specific
1581
1582   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1583
1584   if ( $job ) {  # progress bar
1585     my $error = $job->update_statustext( 0);
1586     die $error if $error;
1587   }
1588
1589   my $cache_dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
1590   my $dir = $cache_dir. 'taxdata';
1591   unless (-d $dir) {
1592     mkdir $dir or die "can't create $dir: $!\n";
1593   }
1594
1595   if ($format eq 'cch') {
1596
1597     my @namelist = qw( code detail geocode plus4 txmatrix zip );
1598
1599     my $conf = new FS::Conf;
1600     die "direct download of tax data not enabled\n" 
1601       unless $conf->exists('taxdatadirectdownload');
1602     my ( $urls, $username, $secret, $states ) =
1603       $conf->config('taxdatadirectdownload');
1604     die "No tax download URL provided.  ".
1605         "Did you set the taxdatadirectdownload configuration value?\n"
1606       unless $urls;
1607
1608     $dir .= '/cch';
1609
1610     my $dbh = dbh;
1611     my $error = '';
1612
1613     # really should get a table EXCLUSIVE lock here
1614     # check if initial import or update
1615     #
1616     # relying on mkdir "$dir.new" as a mutex
1617     
1618     my $sql = "SELECT count(*) from tax_rate WHERE data_vendor='$format'";
1619     my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1620     $sth->execute() or die $sth->errstr;
1621     my $update = $sth->fetchrow_arrayref->[0];
1622
1623     # create cache and/or rotate old tax data
1624
1625     if (-d $dir) {
1626
1627       if (-d "$dir.4") {
1628         opendir(my $dirh, "$dir.4") or die "failed to open $dir.4: $!\n";
1629         foreach my $file (readdir($dirh)) {
1630           unlink "$dir.4/$file" if (-f "$dir.4/$file");
1631         }
1632         closedir($dirh);
1633         rmdir "$dir.4";
1634       }
1635
1636       for (3, 2, 1) {
1637         if ( -e "$dir.$_" ) {
1638           rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
1639         }
1640       }
1641       rename "$dir", "$dir.1" or die "can't rename $dir: $!\n";
1642
1643     } else {
1644
1645       die "can't find previous tax data\n" if $update;
1646
1647     }
1648
1649     mkdir "$dir.new" or die "can't create $dir.new: $!\n";
1650     
1651     # fetch and unpack the zip files
1652
1653     _cch_fetch_and_unzip( $job, $urls, $secret, "$dir.new" );
1654  
1655     # extract csv files from the dbf files
1656
1657     foreach my $name ( @namelist ) {
1658       _cch_extract_csv_from_dbf( $job, $dir, $name ); 
1659     }
1660
1661     # generate the diff files
1662
1663     my @list = ();
1664     foreach my $name ( @namelist ) {
1665       my $difffile = "$dir.new/$name.txt";
1666       if ($update) {
1667         my $error = $job->update_statustext( "0,Comparing to previous $name" );
1668         die $error if $error;
1669         warn "processing $dir.new/$name.txt\n" if $DEBUG;
1670         my $olddir = $update ? "$dir.1" : "";
1671         $difffile = _perform_cch_diff( $name, "$dir.new", $olddir );
1672       }
1673       $difffile =~ s/^$cache_dir//;
1674       push @list, "${name}file:$difffile";
1675     }
1676
1677     # perform the import
1678     local $keep_cch_files = 1;
1679     $param->{uploaded_files} = join( ',', @list );
1680     $param->{format} .= '-update' if $update;
1681     $error ||=
1682       _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
1683     
1684     rename "$dir.new", "$dir"
1685       or die "cch tax update processed, but can't rename $dir.new: $!\n";
1686
1687   }else{
1688     die "Unknown format: $format";
1689   }
1690 }
1691
1692 =item browse_queries PARAMS
1693
1694 Returns a list consisting of a hashref suited for use as the argument
1695 to qsearch, and sql query string.  Each is based on the PARAMS hashref
1696 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
1697 from a form.  This conveniently creates the query hashref and count_query
1698 string required by the browse and search elements.  As a side effect, 
1699 the PARAMS hashref is untainted and keys with unexpected values are removed.
1700
1701 =cut
1702
1703 sub browse_queries {
1704   my $params = shift;
1705
1706   my $query = {
1707                 'table'     => 'tax_rate',
1708                 'hashref'   => {},
1709                 'order_by'  => 'ORDER BY geocode, taxclassnum',
1710               },
1711
1712   my $extra_sql = '';
1713
1714   if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1715     $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1716   } else {
1717     delete $params->{data_vendor};
1718   }
1719    
1720   if ( $params->{geocode} =~ /^(\w+)$/ ) {
1721     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1722                     'geocode LIKE '. dbh->quote($1.'%');
1723   } else {
1724     delete $params->{geocode};
1725   }
1726
1727   if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1728        qsearchs( 'tax_class', {'taxclassnum' => $1} )
1729      )
1730   {
1731     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1732                   ' taxclassnum  = '. dbh->quote($1)
1733   } else {
1734     delete $params->{taxclassnun};
1735   }
1736
1737   my $tax_type = $1
1738     if ( $params->{tax_type} =~ /^(\d+)$/ );
1739   delete $params->{tax_type}
1740     unless $tax_type;
1741
1742   my $tax_cat = $1
1743     if ( $params->{tax_cat} =~ /^(\d+)$/ );
1744   delete $params->{tax_cat}
1745     unless $tax_cat;
1746
1747   my @taxclassnum = ();
1748   if ($tax_type || $tax_cat ) {
1749     my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1750     $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1751     @taxclassnum = map { $_->taxclassnum } 
1752                    qsearch({ 'table'     => 'tax_class',
1753                              'hashref'   => {},
1754                              'extra_sql' => "WHERE taxclass $compare",
1755                           });
1756   }
1757
1758   $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1759                 join(' OR ', map { " taxclassnum  = $_ " } @taxclassnum ). ' )'
1760     if ( @taxclassnum );
1761
1762   unless ($params->{'showdisabled'}) {
1763     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1764                   "( disabled = '' OR disabled IS NULL )";
1765   }
1766
1767   $query->{extra_sql} = $extra_sql;
1768
1769   return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1770 }
1771
1772 =item queue_liability_report PARAMS
1773
1774 Launches a tax liability report.
1775 =cut
1776
1777 sub queue_liability_report {
1778   my $job = shift;
1779   my $param = thaw(decode_base64(shift));
1780
1781   my $cgi = new CGI;
1782   $cgi->param('beginning', $param->{beginning});
1783   $cgi->param('ending', $param->{ending});
1784   my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
1785   my $agentnum = $param->{agentnum};
1786   if ($agentnum =~ /^(\d+)$/) { $agentnum = $1; } else { $agentnum = ''; };
1787   generate_liability_report(
1788     'beginning' => $beginning,
1789     'ending'    => $ending,
1790     'agentnum'  => $agentnum,
1791     'p'         => $param->{RootURL},
1792     'job'       => $job,
1793   );
1794 }
1795
1796 =item generate_liability_report PARAMS
1797
1798 Generates a tax liability report.  Provide a hash including desired
1799 agentnum, beginning, and ending
1800
1801 =cut
1802
1803 sub generate_liability_report {
1804   my %args = @_;
1805
1806   my ( $count, $last, $min_sec ) = _progressbar_foo();
1807
1808   #let us open the temp file early
1809   my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
1810   my $report = new File::Temp( TEMPLATE => 'report.tax.liability.XXXXXXXX',
1811                                DIR      => $dir,
1812                                UNLINK   => 0, # not so temp
1813                              ) or die "can't open report file: $!\n";
1814
1815   my $conf = new FS::Conf;
1816   my $money_char = $conf->config('money_char') || '$';
1817
1818   my $join_cust = "
1819       JOIN cust_bill USING ( invnum ) 
1820       LEFT JOIN cust_main USING ( custnum )
1821   ";
1822
1823   my $join_loc =
1824     "LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum )";
1825   my $join_tax_loc = "LEFT JOIN tax_rate_location USING ( taxratelocationnum )";
1826
1827   my $addl_from = " $join_cust $join_loc $join_tax_loc "; 
1828
1829   my $where = "WHERE _date >= $args{beginning} AND _date <= $args{ending} ";
1830
1831   my $agentname = '';
1832   if ( $args{agentnum} =~ /^(\d+)$/ ) {
1833     my $agent = qsearchs('agent', { 'agentnum' => $1 } );
1834     die "agent not found" unless $agent;
1835     $agentname = $agent->agent;
1836     $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
1837   }
1838
1839   # my ( $location_sql, @location_param ) = FS::cust_pkg->location_sql;
1840   # $where .= " AND $location_sql";
1841   #my @taxparam = ( 'itemdesc', @location_param );
1842   # now something along the lines of geocode matching ?
1843   #$where .= FS::cust_pkg->_location_sql_where('cust_tax_location');;
1844   my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
1845
1846   my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
1847
1848   #false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
1849   #to FS::Report or FS::Record or who the fuck knows where)
1850   my $scalar_sql = sub {
1851     my( $r, $param, $sql ) = @_;
1852     my $sth = dbh->prepare($sql) or die dbh->errstr;
1853     $sth->execute( map $r->$_(), @$param )
1854       or die "Unexpected error executing statement $sql: ". $sth->errstr;
1855     $sth->fetchrow_arrayref->[0] || 0;
1856   };
1857
1858   my $tax = 0;
1859   my $credit = 0;
1860   my %taxes = ();
1861   my %basetaxes = ();
1862   my $calculated = 0;
1863   my @tax_and_location = qsearch({ table     => 'cust_bill_pkg',
1864                                    select    => $select,
1865                                    hashref   => { pkgpart => 0 },
1866                                    addl_from => $addl_from,
1867                                    extra_sql => $where,
1868                                 });
1869   $count = scalar(@tax_and_location);
1870   foreach my $t ( @tax_and_location ) {
1871
1872     if ( $args{job} ) {
1873       if ( time - $min_sec > $last ) {
1874         $args{job}->update_statustext( int( 100 * $calculated / $count ).
1875                                        ",Calculated"
1876                                      );
1877         $last = time;
1878       }
1879     }
1880
1881     my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
1882     my $label = join('~', map { $t->$_ } @params);
1883     $label = 'Tax'. $label if $label =~ /^~/;
1884     unless ( exists( $taxes{$label} ) ) {
1885       my ($baselabel, @trash) = split /~/, $label;
1886
1887       $taxes{$label}->{'label'} = join(', ', split(/~/, $label) );
1888       $taxes{$label}->{'url_param'} =
1889         join(';', map { "$_=". uri_escape($t->$_) } @params);
1890
1891       my $taxwhere = "FROM cust_bill_pkg $addl_from $where AND payby != 'COMP' ".
1892         "AND ". join( ' AND ', map { "( $_ = ? OR ? = '' AND $_ IS NULL)" } @taxparam );
1893
1894       my $sql = "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur) ".
1895                 " $taxwhere AND cust_bill_pkg.pkgnum = 0";
1896
1897       my $x = &{$scalar_sql}($t, [ map { $_, $_ } @params ], $sql );
1898       $tax += $x;
1899       $taxes{$label}->{'tax'} += $x;
1900
1901       my $creditfrom = " JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum) ";
1902       my $creditwhere = "FROM cust_bill_pkg $addl_from $creditfrom $where ".
1903         "AND payby != 'COMP' ".
1904         "AND ". join( ' AND ', map { "( $_ = ? OR ? = '' AND $_ IS NULL)" } @taxparam );
1905
1906       $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
1907              " $creditwhere AND cust_bill_pkg.pkgnum = 0";
1908
1909       my $y = &{$scalar_sql}($t, [ map { $_, $_ } @params ], $sql );
1910       $credit += $y;
1911       $taxes{$label}->{'credit'} += $y;
1912
1913       unless ( exists( $taxes{$baselabel} ) ) {
1914
1915         $basetaxes{$baselabel}->{'label'} = $baselabel;
1916         $basetaxes{$baselabel}->{'url_param'} = "itemdesc=$baselabel";
1917         $basetaxes{$baselabel}->{'base'} = 1;
1918
1919       }
1920
1921       $basetaxes{$baselabel}->{'tax'} += $x;
1922       $basetaxes{$baselabel}->{'credit'} += $y;
1923       
1924     }
1925
1926     # calculate customer-exemption for this tax
1927     # calculate package-exemption for this tax
1928     # calculate monthly exemption (texas tax) for this tax
1929     # count up all the cust_tax_exempt_pkg records associated with
1930     # the actual line items.
1931   }
1932
1933
1934   #ordering
1935
1936   if ( $args{job} ) {
1937     $args{job}->update_statustext( "0,Sorted" );
1938     $last = time;
1939   }
1940
1941   my @taxes = ();
1942
1943   foreach my $tax ( sort { $a cmp $b } keys %taxes ) {
1944     my ($base, @trash) = split '~', $tax;
1945     my $basetax = delete( $basetaxes{$base} );
1946     if ($basetax) {
1947       if ( $basetax->{tax} == $taxes{$tax}->{tax} ) {
1948         $taxes{$tax}->{base} = 1;
1949       } else {
1950         push @taxes, $basetax;
1951       }
1952     }
1953     push @taxes, $taxes{$tax};
1954   }
1955
1956   push @taxes, {
1957     'label'          => 'Total',
1958     'url_param'      => '',
1959     'tax'            => $tax,
1960     'credit'         => $credit,
1961     'base'           => 1,
1962   };
1963
1964
1965   my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
1966   $dateagentlink .= ';agentnum='. $args{agentnum}
1967     if length($agentname);
1968   my $baselink   = $args{p}. "search/cust_bill_pkg.cgi?$dateagentlink";
1969
1970
1971   print $report <<EOF;
1972   
1973     <% include("/elements/header.html", "$agentname Tax Report - ".
1974                   ( $args{beginning}
1975                       ? time2str('%h %o %Y ', $args{beginning} )
1976                       : ''
1977                   ).
1978                   'through '.
1979                   ( $args{ending} == 4294967295
1980                       ? 'now'
1981                       : time2str('%h %o %Y', $args{ending} )
1982                   )
1983               )
1984     %>
1985
1986     <% include('/elements/table-grid.html') %>
1987
1988     <TR>
1989       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
1990       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
1991       <TH CLASS="grid" BGCOLOR="#cccccc">Tax collected</TH>
1992       <TH CLASS="grid" BGCOLOR="#cccccc">&nbsp;&nbsp;&nbsp;&nbsp;</TH>
1993       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
1994       <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
1995     </TR>
1996 EOF
1997
1998   my $bgcolor1 = '#eeeeee';
1999   my $bgcolor2 = '#ffffff';
2000   my $bgcolor = '';
2001  
2002   $count = scalar(@taxes);
2003   $calculated = 0;
2004   foreach my $tax ( @taxes ) {
2005  
2006     if ( $args{job} ) {
2007       if ( time - $min_sec > $last ) {
2008         $args{job}->update_statustext( int( 100 * $calculated / $count ).
2009                                        ",Generated"
2010                                      );
2011         $last = time;
2012       }
2013     }
2014
2015     if ( $bgcolor eq $bgcolor1 ) {
2016       $bgcolor = $bgcolor2;
2017     } else {
2018       $bgcolor = $bgcolor1;
2019     }
2020  
2021     my $link = '';
2022     if ( $tax->{'label'} ne 'Total' ) {
2023       $link = ';'. $tax->{'url_param'};
2024     }
2025  
2026     print $report <<EOF;
2027       <TR>
2028         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"><% '$tax->{label}' %></TD>
2029         <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2030         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2031           <A HREF="<% '$baselink$link' %>;istax=1"><% '$money_char' %><% sprintf('%.2f', $tax->{'tax'} ) %></A>
2032         </TD>
2033         <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2034         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
2035         <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2036         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2037           <A HREF="<% '$baselink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
2038         </TD>
2039         <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2040       </TR>
2041 EOF
2042   } 
2043
2044   print $report <<EOF;
2045     </TABLE>
2046
2047     </BODY>
2048     </HTML>
2049 EOF
2050
2051   my $reportname = $report->filename;
2052   close $report;
2053
2054   my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
2055   $reportname =~ s/^$dropstring//;
2056
2057   my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname";
2058   die "<a href=$reporturl>view</a>\n";
2059
2060 }
2061
2062
2063
2064 =back
2065
2066 =head1 BUGS
2067
2068   Mixing automatic and manual editing works poorly at present.
2069
2070   Tax liability calculations take too long and arguably don't belong here.
2071   Tax liability report generation not entirely safe (escaped).
2072
2073 =head1 SEE ALSO
2074
2075 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
2076 documentation.
2077
2078 =cut
2079
2080 1;
2081